// src/components/Cart/CartContext.tsx // Mobile-Optimized Cart Context (4-language i18n support + Discount + First Item Helper) import React, { createContext, useContext, useReducer, useEffect, useCallback, useMemo, } from "react"; import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { DiscountUtils } from "../../firebase/services"; import type { Product } from "../MainMenu/ProductCard"; /* ───────────────────────── TYPES ───────────────────────── */ export interface CartItem extends Product { quantity: number; addedAt: Date; /** Calculated fields for UI */ effectivePrice?: number; // unit price after discount (if any) totalSavings?: number; // total savings for this line (qty * per-item savings) discountApplied?: boolean; } interface CartState { items: CartItem[]; isLoading: boolean; lastUpdated: Date | null; totalItems: number; totalPrice: number; // effective total (after discounts) originalTotalPrice: number; // sum of original prices (no discount) totalSavings: number; // original - effective discountedItemsCount: number; // count of items (qty) that have discount applied // ✅ First item helper tracking previousItemCount: number; } export interface CartContextType { // data items: CartItem[]; totalItems: number; totalPrice: number; originalTotalPrice: number; totalSavings: number; discountedItemsCount: number; isLoading: boolean; lastUpdated: Date | null; cartValue: number; // actions addToCart: (product: Product, showHelper?: boolean) => void; removeFromCart: (productId: string) => void; updateQuantity: (productId: string, quantity: number) => void; clearCart: () => void; // helpers isInCart: (productId: string) => boolean; getCartItem: (productId: string) => CartItem | undefined; hasDiscountedItems: () => boolean; // ✅ First item helper callbacks onFirstItemAdded?: (item: CartItem) => void; setFirstItemCallback: (callback: (item: CartItem) => void) => void; } /* ───────────────────────── CONSTANTS ───────────────────────── */ // Storage keys (debounced & expirable) const STORAGE_KEY = "sushizen-cart-v2"; const STORAGE_EXPIRY_KEY = "sushizen-cart-expiry"; // Legacy migration keys const LEGACY_KEYS = ["sushi-cart", "sushizen-cart-v1"] as const; // Debounced save singleton let saveTimeout: ReturnType | null = null; // Limits const MAX_CART_ITEMS = 50; const CART_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24h const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5m const MAX_ITEM_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7d /* ───────────────────────── CALC HELPERS ───────────────────────── */ const calcItemTotals = (p: Product, qty: number) => { const originalPrice = p.price; const valid = p.discount && DiscountUtils.isDiscountValid(p.discount); if (valid) { const eff = DiscountUtils.calculateDiscountedPrice( originalPrice, p.discount ); const savePer = DiscountUtils.calculateSavings(originalPrice, p.discount); return { effectivePrice: eff, totalOriginalPrice: originalPrice * qty, totalEffectivePrice: eff * qty, totalSavings: savePer * qty, discountApplied: true, }; } return { effectivePrice: originalPrice, totalOriginalPrice: originalPrice * qty, totalEffectivePrice: originalPrice * qty, totalSavings: 0, discountApplied: false, }; }; const calcCartTotals = (items: CartItem[]) => { let totalItems = 0; let totalPrice = 0; let originalTotalPrice = 0; let totalSavings = 0; let discountedItemsCount = 0; for (const it of items) { const t = calcItemTotals(it, it.quantity); totalItems += it.quantity; totalPrice += t.totalEffectivePrice; originalTotalPrice += t.totalOriginalPrice; totalSavings += t.totalSavings; if (t.discountApplied) discountedItemsCount += it.quantity; } return { totalItems, totalPrice, originalTotalPrice, totalSavings, discountedItemsCount, }; }; /* ───────────────────────── REDUCER ───────────────────────── */ type Action = | { type: "SET_LOADING"; payload: boolean } | { type: "SET_ITEMS"; payload: CartItem[] } | { type: "ADD"; payload: { product: Product; triggerHelper: boolean } } | { type: "REMOVE"; payload: string } | { type: "QTY"; payload: { id: string; qty: number } } | { type: "CLEAR" } | { type: "RECALC" } | { type: "TOUCH" }; const initialState: CartState = { items: [], isLoading: true, lastUpdated: null, totalItems: 0, totalPrice: 0, originalTotalPrice: 0, totalSavings: 0, discountedItemsCount: 0, previousItemCount: 0, }; const withRecalc = (state: CartState, items: CartItem[]): CartState => { // enrich inline calc fields const normalized = items.map((it) => { const t = calcItemTotals(it, it.quantity); return { ...it, effectivePrice: t.effectivePrice, totalSavings: t.totalSavings, discountApplied: t.discountApplied, }; }); const totals = calcCartTotals(normalized); return { ...state, items: normalized, ...totals, lastUpdated: new Date(), previousItemCount: state.totalItems, }; }; // ✅ Global first item callback for helper let globalFirstItemCallback: ((item: CartItem) => void) | null = null; const reducer = (state: CartState, action: Action): CartState => { switch (action.type) { case "SET_LOADING": return { ...state, isLoading: action.payload }; case "SET_ITEMS": return withRecalc(state, action.payload); case "ADD": { const { product: p, triggerHelper } = action.payload; const found = state.items.find((i) => i.id === p.id); const willBeFirstItem = state.totalItems === 0 && !found; if (found) { const items = state.items.map((i) => i.id === p.id ? { ...i, quantity: i.quantity + 1 } : i ); return withRecalc(state, items); } const newItem: CartItem = { ...p, quantity: 1, addedAt: new Date(), }; const newState = withRecalc(state, [...state.items, newItem]); if (willBeFirstItem && triggerHelper && globalFirstItemCallback) { setTimeout(() => { globalFirstItemCallback?.(newItem); }, 100); } return newState; } case "REMOVE": { const items = state.items.filter((i) => i.id !== action.payload); return withRecalc(state, items); } case "QTY": { const { id, qty } = action.payload; if (qty <= 0) { const items = state.items.filter((i) => i.id !== id); return withRecalc(state, items); } const items = state.items.map((i) => i.id === id ? { ...i, quantity: qty } : i ); return withRecalc(state, items); } case "CLEAR": return { ...initialState, isLoading: false, lastUpdated: new Date(), previousItemCount: state.totalItems, }; case "RECALC": return withRecalc(state, state.items); case "TOUCH": return { ...state, lastUpdated: new Date() }; default: return state; } }; /* ───────────────────────── i18n HELPERS ───────────────────────── */ /** Map i18n code -> best-fit Intl locale */ const localeForIntl = (lng: string) => { const base = lng?.toLowerCase?.() || "nl"; if (base.startsWith("nl")) return "nl-NL"; if (base.startsWith("en")) return "en-GB"; if (base.startsWith("de")) return "de-DE"; if (base.startsWith("fr")) return "fr-FR"; return "nl-NL"; }; type CartLocalePack = { addedWithName: string; // "{{name}} added..." firstItemAdded: string; youSaved: string; // "You saved {{amount}}." removedWithName: string; savingsReminder: string; // "You're saving {{amount}}..." discountExpiring: string; // "Discount on {{name}} expires in {{hours}} hours!" }; const getCartLocalePack = (lng: string): CartLocalePack => { const L = lng.toLowerCase(); if (L.startsWith("de")) return { addedWithName: "{{name}} wurde zum Warenkorb hinzugefügt", firstItemAdded: "🎉 Erster Artikel hinzugefügt! Öffne deinen Warenkorb oben.", youSaved: "💰 Du hast {{amount}} gespart.", removedWithName: "{{name}} wurde aus dem Warenkorb entfernt", savingsReminder: "💰 Du sparst insgesamt {{amount}} in deinem Warenkorb!", discountExpiring: "⏰ Der Rabatt auf {{name}} läuft in {{hours}} Stunden ab!", }; if (L.startsWith("fr")) return { addedWithName: "{{name}} a été ajouté à votre panier", firstItemAdded: "🎉 Premier article ajouté ! Consultez votre panier en haut.", youSaved: "💰 Vous avez économisé {{amount}}.", removedWithName: "{{name}} a été retiré de votre panier", savingsReminder: "💰 Vous économisez {{amount}} dans votre panier !", discountExpiring: "⏰ La remise sur {{name}} expire dans {{hours}} heures !", }; if (L.startsWith("en")) return { addedWithName: "{{name}} added to your cart", firstItemAdded: "🎉 First item added! Check your cart at the top.", youSaved: "💰 You saved {{amount}}.", removedWithName: "{{name}} removed from your cart", savingsReminder: "💰 You're saving {{amount}} in your cart!", discountExpiring: "⏰ Discount on {{name}} expires in {{hours}} hours!", }; // nl default return { addedWithName: "{{name}} is toegevoegd aan je winkelwagen", firstItemAdded: "🎉 Eerste item toegevoegd! Bekijk je winkelwagen bovenaan.", youSaved: "💰 {{amount}} bespaard.", removedWithName: "{{name}} is uit je winkelwagen verwijderd", savingsReminder: "💰 Je bespaart in totaal {{amount}} in je winkelwagen!", discountExpiring: "⏰ De korting op {{name}} verloopt over {{hours}} uur!", }; }; /* ───────────────────────── CONTEXT ───────────────────────── */ const CartContext = createContext(undefined); /* ───────────────────────── PROVIDER ───────────────────────── */ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const [state, dispatch] = useReducer(reducer, initialState); const { t, i18n } = useTranslation(); // Locale pack & currency formatter are reactive to language const L = useMemo( () => getCartLocalePack(i18n.language || "nl"), [i18n.language] ); const formatCurrency = useCallback( (value: number) => new Intl.NumberFormat(localeForIntl(i18n.language || "nl"), { style: "currency", currency: "EUR", maximumFractionDigits: 2, }).format(value), [i18n.language] ); /* ── Load from storage (expiry + migration) ── */ useEffect(() => { const load = async () => { try { dispatch({ type: "SET_LOADING", payload: true }); if (typeof window === "undefined") { dispatch({ type: "SET_LOADING", payload: false }); return; } // expiry const expiry = localStorage.getItem(STORAGE_EXPIRY_KEY); const now = Date.now(); if (expiry && now > parseInt(expiry)) { localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_EXPIRY_KEY); dispatch({ type: "SET_LOADING", payload: false }); return; } // load current or legacy let raw = localStorage.getItem(STORAGE_KEY); if (!raw) { for (const k of LEGACY_KEYS) { raw = localStorage.getItem(k) || raw; } } if (raw) { const parsed = JSON.parse(raw); const arr: CartItem[] = Array.isArray(parsed) ? parsed : []; const validated: CartItem[] = arr .filter( (it: any) => it && it.id && typeof it.price === "number" && it.quantity > 0 ) .map((it: any) => ({ ...it, addedAt: new Date(it.addedAt ?? new Date()), imageUrl: it.imageUrl || "/placeholder-image.jpg", categories: Array.isArray(it.categories) ? it.categories : [], ingredients: Array.isArray(it.ingredients) ? it.ingredients : [], })); if (validated.length) { dispatch({ type: "SET_ITEMS", payload: validated }); } } } catch (e) { console.warn("Cart load failed, clearing:", e); localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_EXPIRY_KEY); } finally { setTimeout( () => dispatch({ type: "SET_LOADING", payload: false }), 100 ); } }; load(); }, []); /* ── Debounced persist to localStorage ── */ const saveToStorage = useCallback((items: CartItem[]) => { if (typeof window === "undefined") return; if (saveTimeout) clearTimeout(saveTimeout); saveTimeout = setTimeout(() => { try { if (items.length) { const expiry = Date.now() + CART_EXPIRY_MS; localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); localStorage.setItem(STORAGE_EXPIRY_KEY, String(expiry)); } else { localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_EXPIRY_KEY); } } catch (err: any) { console.error("Failed to save cart:", err); if ( err?.name === "QuotaExceededError" || (err instanceof DOMException && (err.name === "QuotaExceededError" || err.code === 22)) ) { try { const essential = items.map((it) => ({ id: it.id, name: it.name, price: it.price, quantity: it.quantity, addedAt: it.addedAt, imageUrl: it.imageUrl, categories: (it.categories || []).slice(0, 2), })); localStorage.setItem(STORAGE_KEY, JSON.stringify(essential)); } catch (retryErr) { console.error("Essential save failed:", retryErr); } } } finally { dispatch({ type: "TOUCH" }); } }, 300); }, []); useEffect(() => { if (!state.isLoading) saveToStorage(state.items); }, [state.items, state.isLoading, saveToStorage]); /* ── First item callback management ── */ const setFirstItemCallback = useCallback( (callback: (item: CartItem) => void) => { globalFirstItemCallback = callback; }, [] ); /* ── Actions ─────────────────────────────────────────────── */ const addToCart = useCallback( (product: Product, showHelper: boolean = true) => { const shouldTriggerHelper = showHelper && state.totalItems === 0; dispatch({ type: "ADD", payload: { product, triggerHelper: shouldTriggerHelper }, }); // Haptic if (typeof navigator !== "undefined" && (navigator as any).vibrate) { (navigator as any).vibrate(50); } // Toasts if (product.discount && DiscountUtils.isDiscountValid(product.discount)) { const savings = DiscountUtils.calculateSavings( product.price, product.discount ); const title = t("itemAddedToCart"); // i18n key exists const savedText = L.youSaved.replace( "{{amount}}", formatCurrency(savings) ); toast.success(`${product.name} — ${title}\n${savedText}`, { duration: 3500, icon: "🛒", style: { background: "linear-gradient(135deg,#d32f2f 0%,#f44336 100%)", color: "#fff", fontWeight: 600, }, }); } else { if (shouldTriggerHelper) { toast.success(L.firstItemAdded, { icon: "🛒", duration: 4000, style: { background: "linear-gradient(135deg,#2e7d32 0%,#388e3c 100%)", color: "#fff", fontWeight: 600, }, }); } else { const msg = L.addedWithName.replace("{{name}}", product.name); toast.success(msg, { icon: "🛒" }); } } }, [state.totalItems, t, L, formatCurrency] ); const removeFromCart = useCallback( (productId: string) => { const item = state.items.find((i) => i.id === productId); dispatch({ type: "REMOVE", payload: productId }); // Haptic if (typeof navigator !== "undefined" && (navigator as any).vibrate) { (navigator as any).vibrate([50, 50, 50]); } if (item) { // Combine localized product-aware + base translation const msg = L.removedWithName.replace("{{name}}", item.name) + ` — ${t("itemRemovedFromCart")}`; toast.success(msg, { icon: "🗑️" }); } }, [state.items, t, L] ); const updateQuantity = useCallback((productId: string, quantity: number) => { dispatch({ type: "QTY", payload: { id: productId, qty: quantity } }); }, []); const clearCart = useCallback(() => { dispatch({ type: "CLEAR" }); // Haptic if (typeof navigator !== "undefined" && (navigator as any).vibrate) { (navigator as any).vibrate([100, 50, 100]); } toast.success(t("cartCleared"), { icon: "🧹" }); }, [t]); const isInCart = useCallback( (productId: string) => state.items.some((i) => i.id === productId), [state.items] ); const getCartItem = useCallback( (productId: string) => state.items.find((i) => i.id === productId), [state.items] ); const hasDiscountedItems = useCallback( () => state.discountedItemsCount > 0, [state.discountedItemsCount] ); /* ── Auto recalculation (discounts may expire) ── */ useEffect(() => { const id = setInterval(() => dispatch({ type: "RECALC" }), 60_000); return () => clearInterval(id); }, []); /* ── Mobile: limit cart size ── */ useEffect(() => { if (state.items.length > MAX_CART_ITEMS) { console.warn( `Cart size exceeded ${MAX_CART_ITEMS} items, removing oldest items` ); const trimmed = [...state.items] .sort((a, b) => b.addedAt.getTime() - a.addedAt.getTime()) .slice(0, MAX_CART_ITEMS); dispatch({ type: "SET_ITEMS", payload: trimmed }); } }, [state.items.length]); /* ── Online/Offline events ── */ useEffect(() => { const onOnline = () => console.log("Back online, cart synced"); const onOffline = () => console.log("Going offline, cart saved locally"); window.addEventListener("online", onOnline); window.addEventListener("offline", onOffline); return () => { window.removeEventListener("online", onOnline); window.removeEventListener("offline", onOffline); }; }, []); /* ── Cleanup old items (memory) ── */ useEffect(() => { const interval = setInterval(() => { const now = Date.now(); const filtered = state.items.filter( (it) => now - it.addedAt.getTime() < MAX_ITEM_AGE_MS ); if (filtered.length !== state.items.length) { dispatch({ type: "SET_ITEMS", payload: filtered }); } }, CLEANUP_INTERVAL_MS); return () => clearInterval(interval); }, [state.items]); /* ── Context value ─────────────────────────────────────────── */ const contextValue: CartContextType = { items: state.items, totalItems: state.totalItems, totalPrice: state.totalPrice, originalTotalPrice: state.originalTotalPrice, totalSavings: state.totalSavings, discountedItemsCount: state.discountedItemsCount, isLoading: state.isLoading, lastUpdated: state.lastUpdated, cartValue: state.totalPrice, addToCart, removeFromCart, updateQuantity, clearCart, isInCart, getCartItem, hasDiscountedItems, setFirstItemCallback, }; return ( {children} ); }; /* ───────────────────────── HOOKS ───────────────────────── */ export const useCart = (): CartContextType => { const ctx = useContext(CartContext); if (!ctx) throw new Error("useCart must be used within a CartProvider"); return ctx; }; /** PWA badge + title + savings/expiry notifications (i18n-ready) */ export const useCartNotifications = () => { const { items, totalItems, hasDiscountedItems, totalSavings } = useCart(); const { t, i18n } = useTranslation(); const L = useMemo( () => getCartLocalePack(i18n.language || "nl"), [i18n.language] ); const formatCurrency = useCallback( (value: number) => new Intl.NumberFormat(localeForIntl(i18n.language || "nl"), { style: "currency", currency: "EUR", maximumFractionDigits: 2, }).format(value), [i18n.language] ); // Badge & title useEffect(() => { if (typeof navigator !== "undefined" && "setAppBadge" in navigator) { if (totalItems > 0) { (navigator as any).setAppBadge(totalItems); } else { (navigator as any).clearAppBadge?.(); } } const baseTitle = `${t("restaurantName")} - Premium Sushi`; document.title = totalItems > 0 ? `(${totalItems}) ${baseTitle}` : baseTitle; }, [totalItems, t]); // Savings reminder (after 30s) useEffect(() => { if (!hasDiscountedItems() || totalSavings <= 0) return; const tmr = setTimeout(() => { const msg = L.savingsReminder.replace( "{{amount}}", formatCurrency(totalSavings) ); toast(msg, { duration: 3000, icon: "💸", style: { background: "linear-gradient(135deg,#2e7d32 0%,#388e3c 100%)", color: "#fff", fontWeight: 600, }, }); }, 30_000); return () => clearTimeout(tmr); }, [hasDiscountedItems, totalSavings, L, formatCurrency]); // Expiring discount warnings (every 5m) useEffect(() => { const check = () => { const now = new Date(); items.forEach((it) => { const d = it.discount; if (!d) return; if (!DiscountUtils.isDiscountValid(d)) return; if (!d.endDate) return; const end = d.endDate instanceof Date ? d.endDate : new Date(d.endDate as any); const hours = (end.getTime() - now.getTime()) / (1000 * 60 * 60); if (hours <= 24 && hours > 0) { const msg = L.discountExpiring .replace("{{name}}", it.name) .replace("{{hours}}", String(Math.ceil(hours))); toast(msg, { duration: 5000, icon: "⚠️", style: { background: "linear-gradient(135deg,#ff9800 0%,#f57c00 100%)", color: "#fff", fontWeight: 600, }, }); } }); }; const id = setInterval(check, 300_000); return () => clearInterval(id); }, [items, L]); }; /** Basic performance logging */ export const useCartPerformance = () => { const { items, totalItems, totalPrice, totalSavings, discountedItemsCount } = useCart(); useEffect(() => { const metrics = { itemCount: totalItems, totalValue: totalPrice, totalSavings, discountedItemsCount, hasDiscounts: discountedItemsCount > 0, sizeBytes: JSON.stringify(items).length, ts: new Date().toISOString(), }; console.log("Cart Performance:", metrics); if (metrics.sizeBytes > 50_000) { console.warn( `Cart size is large: ${metrics.sizeBytes} bytes with ${metrics.itemCount} items` ); } if (typeof performance !== "undefined" && (performance as any).mark) { (performance as any).mark("cart-updated"); } }, [items, totalItems, totalPrice, totalSavings, discountedItemsCount]); }; export default CartProvider;