takescake

Cart Persistence and Foil Pricing: Major Shopping Cart Upgrades

2025-10-05

Cart Persistence and Foil Pricing: Major Shopping Cart Upgrades

We've shipped two critical improvements to the shopping cart experience based on user feedback:

1. Fixed Cart Emptying on Navigation

The Problem: Users reported that adding cards to their cart, then navigating to a card detail page would empty the cart. This was caused by a React hydration mismatch where the server rendered an empty cart state, but the client tried to load from localStorage, causing Next.js to throw hydration errors and reset the cart.

The Fix: We implemented proper client-side hydration handling:

export function ScanCartProvider({ children }: { children: ReactNode }) {
  const [items, setItems] = useState<ScanCartItem[]>([]);
  const [isHydrated, setIsHydrated] = useState(false);

  useEffect(() => {
    // Load cart from localStorage AFTER hydration
    setItems(loadInitialCart());
    setIsHydrated(true);
  }, []);

  useEffect(() => {
    if (isHydrated && typeof window !== 'undefined') {
      persistCart(items);
    }
  }, [items, isHydrated]);
  // ... rest of provider
}

The key insight: Start with empty state on both server and client, then load localStorage data in a useEffect. This ensures consistent initial renders and prevents hydration errors.

Similarly, the FloatingCartButton now only renders on the client:

export function FloatingCartButton({ onClick }: FloatingCartButtonProps) {
  const { totalCount, estimatedTotal } = useScanCart();
  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    setIsClient(true);
  }, []);

  // Don't render on server or if cart is empty
  if (!isClient || totalCount === 0) {
    return null;
  }
  // ... render button
}

Result: Cart data now persists perfectly across all page navigation. Add cards from anywhere on the site, browse card details, use the scanner, search for more cardsβ€”your cart stays intact until you explicitly clear it or navigate to checkout.

2. Foil/Non-Foil Finish Selection

The Problem: The original AddToCartButton always added cards as non-foil (isFoil: false), even when foil pricing data was available. Users had no way to specify which finish they wanted.

The Fix: We built intelligent finish detection and a dynamic UI that appears only when both finishes are available:

Automatic Foil Detection

function hasFoilPricing(card: AddToCartButtonProps['card']): boolean {
  // Check structured pricing
  if (card.pricing?.foil) {
    const foil = card.pricing.foil;
    if (foil.market || foil.low || foil.mid || foil.high || foil.directLow) {
      return true;
    }
  }
  // Check tcgcsv subType for foil indicator
  if (card.tcgcsv?.subType && /foil/i.test(card.tcgcsv.subType)) {
    return true;
  }
  return false;
}

function hasNormalPricing(card: AddToCartButtonProps['card']): boolean {
  // Check structured pricing
  if (card.pricing?.normal) {
    const normal = card.pricing.normal;
    if (normal.market || normal.low || normal.mid || normal.high || normal.directLow) {
      return true;
    }
  }
  // Check tcgcsv for non-foil pricing
  if (card.tcgcsv && (!card.tcgcsv.subType || !/foil/i.test(card.tcgcsv.subType))) {
    if (card.tcgcsv.market || card.tcgcsv.low || card.tcgcsv.mid || card.tcgcsv.high) {
      return true;
    }
  }
  return false;
}

Dynamic UI Based on Availability

The button now adapts to what's available:

  1. Both finishes available β†’ Show finish selector (Normal/Foil ✨ toggle buttons) + Add to Cart button
  2. Only foil available β†’ Add to Cart button (automatically adds as foil)
  3. Only normal available β†’ Add to Cart button (automatically adds as normal)
const showFoilOption = hasFoilPricing(card);
const showNormalOption = hasNormalPricing(card);
const showFinishSelector = showFoilOption && showNormalOption;

return (
  <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', ...style }}>
    {showFinishSelector && (
      <div style={{
        display: 'flex',
        gap: '0.5rem',
        padding: '0.5rem',
        background: '#f5f7ff',
        borderRadius: '12px',
        border: '1px solid #e3e9ff'
      }}>
        <button onClick={() => setSelectedFinish('normal')} /* ... */>
          Normal
        </button>
        <button onClick={() => setSelectedFinish('foil')} /* ... */>
          Foil ✨
        </button>
      </div>
    )}
    <button onClick={() => handleAdd(showFinishSelector ? selectedFinish === 'foil' : showFoilOption)}>
      πŸ›’ Add to Cart
    </button>
  </div>
);

Accurate Cart Pricing

The cart sidebar and floating button now respect the isFoil flag when calculating totals:

function getUnitPrice(item: ScanCartItem): number | null {
  const primary = item.isFoil ? item.pricing?.foil : item.pricing?.normal;
  const secondary = item.isFoil ? item.pricing?.normal : item.pricing?.foil;
  const primaryPrice = pickBestPrice(primary ?? undefined);
  if (primaryPrice != null) {
    return primaryPrice;
  }
  // Fallback to secondary finish if primary not available
  const secondaryPrice = pickBestPrice(secondary ?? undefined);
  if (secondaryPrice != null) {
    return secondaryPrice;
  }
  // Last resort: legacy tcgcsv data
  return pickBestPrice(item.tcgcsv ?? undefined);
}

Result: Users can now specify exactly which finish they want when adding cards to their cart. The cart displays "(Foil)" or "(Non-Foil)" labels per item, and calculates totals using the correct pricing for each finish.

Real-World Use Cases

Card Show Shopping

You're at the Minnesota Card Show and find a vendor with a foil [[Rhystic Study]]:

  1. Scan the card or search by name
  2. See both Normal ($38.50) and Foil ✨ ($78.20) options
  3. Toggle to "Foil ✨" since that's what the vendor has
  4. Add to cart β†’ cart shows "Rhystic Study (Foil) β€” $78.20"
  5. Browse more vendors, add more cards
  6. Navigate to other card detail pages to check legality
  7. Cart stays intact with all your items and pricing

Budget Brewing

Building a Commander deck and tracking which upgrades to splurge on:

  1. Add the budget non-foil versions of cards you need
  2. For your commander, toggle to Foil ✨ to pimp it out
  3. Cart total shows: "12 items β€” $147.50"
  4. Navigate around the site checking synergies
  5. Add more cards, use deck builder, search for alternatives
  6. Cart never empties β€” come back hours later, it's still there

Collection Management

Tracking which cards you need to complete your collection:

  1. Add cards as you identify gaps
  2. Choose foil for cards you want to upgrade
  3. Use scanner at trade shows to compare vendor prices
  4. Cart persists across sessions via localStorage
  5. Export or clear when you're done

Technical Details

Data Flow

Card Detail Page
  ↓ (pricing data from tcgcsv-prices.json + cards-index.json)
AddToCartButton
  ↓ (detects available finishes)
  ↓ (user selects normal/foil)
ScanCartContext
  ↓ (stores in state + localStorage)
FloatingCartButton (shows count/total)
ScanCartSidebar (shows full cart)

Pricing Source Priority

  1. Structured pricing object (normal/foil separated)
  2. Legacy tcgcsv object (uses subType to detect foil)
  3. Scryfall prices (price_usd / price_usd_foil)

Hydration Strategy

The fix for cart persistence follows Next.js best practices for localStorage:

  1. Server render: Empty state (no access to localStorage)
  2. Client hydrate: Empty state (matches server)
  3. useEffect: Load from localStorage (client-only)
  4. Subsequent updates: Save to localStorage

This prevents the dreaded "Hydration failed" error while maintaining snappy performance.

Lessons Learned

Hydration Debugging

React hydration errors can be cryptic. The key insight:

Server and client MUST render identical initial HTML. Any divergence causes React to throw hydration errors and potentially reset state.

Common culprits:

  • Reading localStorage during initial render
  • Conditional rendering based on browser APIs
  • Date/time formatting (timezone differences)
  • Random values (Math.random(), uuid generation)

Solution: Use useEffect + client-only state flags to defer dynamic content until after hydration.

Feature Detection Over Assumptions

Instead of assuming all cards have both finishes, we detect what's actually available:

const showFoilOption = hasFoilPricing(card);
const showNormalOption = hasNormalPricing(card);

This gracefully handles:

  • Cards that only exist in foil (promos, special editions)
  • Cards that only exist in non-foil (some old sets)
  • Cards with incomplete pricing data
  • Cards from tcgcsv vs. Scryfall data sources

Progressive Enhancement

The cart experience degrades gracefully:

  1. Ideal: Both finishes available β†’ full selector UI
  2. Good: One finish available β†’ simple add button
  3. Acceptable: No pricing β†’ still shows set/rarity info
  4. Fallback: Cart uses secondary finish price if primary missing

Users always get something useful, even with incomplete data.

Future Enhancements

Special Finishes

Extend beyond normal/foil to support:

  • Etched foil
  • Extended art
  • Borderless
  • Showcase frames
  • Serialized cards

Implementation: Add finishType: 'normal' | 'foil' | 'etched' | 'extended' | 'borderless' | 'showcase' | 'serialized' to cart items.

Price Tracking

Add "Save for Later" feature that notifies when card prices drop:

interface SavedCard extends ScanCartItem {
  savedAt: string;
  targetPrice?: number;
  priceHistory: Array<{ date: string; price: number }>;
}

Bulk Operations

Add cart management features:

  • "Make all foil" / "Make all normal" bulk toggle
  • Import deck list β†’ auto-add to cart
  • Export cart β†’ shareable URL or CSV
  • Optimize cart (suggest cheaper printings)

Vendor Comparison

For card shows, compare vendor prices in cart:

interface VendorPrice {
  vendor: string;
  booth?: string;
  price: number;
  condition: 'NM' | 'LP' | 'MP' | 'HP';
}

interface CartItemWithVendors extends ScanCartItem {
  vendorPrices?: VendorPrice[];
}

Try It Now

  1. Visit any card page (e.g., [[Sol Ring]], [[Rhystic Study]], [[Mystical Tutor]])
  2. Look for the "Add to Cart" section below the card image
  3. Toggle between Normal/Foil ✨ if both are available
  4. Add to cart and watch the floating button appear
  5. Navigate to other pages β€” cart stays intact!
  6. Click the floating button to view full cart at /scan

Related Features

Attribution

Card data and images courtesy of Scryfall. Price data from TCGplayer via tcgcsv.com. Β© Wizards of the Coast.


Questions or issues? The cart should now persist perfectly across navigation and offer foil selection for all eligible cards. If you encounter any problems, it might be a browser localStorage issue or missing pricing data for specific cards.

Related Posts