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:
- Both finishes available β Show finish selector (Normal/Foil β¨ toggle buttons) + Add to Cart button
- Only foil available β Add to Cart button (automatically adds as foil)
- 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]]:
- Scan the card or search by name
- See both Normal ($38.50) and Foil β¨ ($78.20) options
- Toggle to "Foil β¨" since that's what the vendor has
- Add to cart β cart shows "Rhystic Study (Foil) β $78.20"
- Browse more vendors, add more cards
- Navigate to other card detail pages to check legality
- Cart stays intact with all your items and pricing
Budget Brewing
Building a Commander deck and tracking which upgrades to splurge on:
- Add the budget non-foil versions of cards you need
- For your commander, toggle to Foil β¨ to pimp it out
- Cart total shows: "12 items β $147.50"
- Navigate around the site checking synergies
- Add more cards, use deck builder, search for alternatives
- Cart never empties β come back hours later, it's still there
Collection Management
Tracking which cards you need to complete your collection:
- Add cards as you identify gaps
- Choose foil for cards you want to upgrade
- Use scanner at trade shows to compare vendor prices
- Cart persists across sessions via localStorage
- 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
- Structured
pricing
object (normal/foil separated) - Legacy
tcgcsv
object (uses subType to detect foil) - Scryfall prices (price_usd / price_usd_foil)
Hydration Strategy
The fix for cart persistence follows Next.js best practices for localStorage:
- Server render: Empty state (no access to localStorage)
- Client hydrate: Empty state (matches server)
- useEffect: Load from localStorage (client-only)
- 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:
- Ideal: Both finishes available β full selector UI
- Good: One finish available β simple add button
- Acceptable: No pricing β still shows set/rarity info
- 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
- Visit any card page (e.g., [[Sol Ring]], [[Rhystic Study]], [[Mystical Tutor]])
- Look for the "Add to Cart" section below the card image
- Toggle between Normal/Foil β¨ if both are available
- Add to cart and watch the floating button appear
- Navigate to other pages β cart stays intact!
- Click the floating button to view full cart at
/scan
Related Features
- MTG Card Scanner: Hybrid OCR + Art Matching + Pricing - Scan physical cards and add to cart
- Trade Show Floating Cart & Quick Search - Quick search and cart for card shows
- Scanner Foil Pricing & Cart Upgrade - Foil pricing in scanner workflow
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.