'use client'; /** * ============================================================================= * GLOBAL CART SYSTEM — Shahi Kitchen * ============================================================================= * * This file implements a production-grade, persistent shopping cart using: * • React Context + useReducer (predictable state machine) * • localStorage persistence (survives page refresh / browser restart) * • Sonner toasts with "View Cart" action * • Derived totals (totalItems, totalPrice) computed on every render * * WHY THIS ARCHITECTURE? * - useReducer gives us a single source of truth and easy debugging (time-travel capable) * - Context lets ANY component (menu cards, navbar icon, drawer, future checkout) * access cart state without prop drilling * - localStorage key is namespaced ("shahi-kitchen-cart") so multiple sites on same * domain won't collide * * IMPORTANT INTEGRATION POINTS: * - CartProvider is mounted ONCE in app/layout.tsx (root of the tree) * - useCart() hook is used in: * • app/menu/page.tsx → addToCart() on every dish * • components/Navbar.tsx → shows live count + opens drawer * • components/CartDrawer.tsx → full management UI + WhatsApp ordering * * WHATSAPP ORDER FLOW (the "checkout" for this restaurant): * The cart never talks to a real payment gateway. Instead, the drawer builds a * beautifully formatted message and opens https://wa.me/46709864995 with it pre-filled. * This matches exactly how the reservation form on the homepage works. * * FUTURE IMPROVEMENTS (documented for next developer): * - Add "special instructions" per item or for the whole order * - Persist cart across different devices via Supabase / Firebase (when real ordering launches) * - Add estimated pickup time or "order for later" picker * - Minimum order value enforcement * * GOTCHAS: * - The localStorage restore logic runs only once on mount. It dispatches multiple * actions on purpose so the reducer stays pure. * - Toasts are intentionally fired ONLY from addToCart(), never from restore. */ import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react'; import { toast } from 'sonner'; /** * TYPE DEFINITIONS * * CartItem — the shape stored in state and localStorage. * image is optional because some drinks don't have photos yet. * * CartState — minimal internal state. We derive totalItems/totalPrice in the provider * so consumers never have to recalculate. * * CartAction — discriminated union. This is what makes the reducer easy to reason about. */ export interface CartItem { id: string; name: string; price: number; quantity: number; image?: string; } interface CartState { items: CartItem[]; isOpen: boolean; } type CartAction = | { type: 'ADD_ITEM'; payload: Omit } | { type: 'REMOVE_ITEM'; payload: string } | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } } | { type: 'CLEAR_CART' } | { type: 'TOGGLE_CART' } | { type: 'OPEN_CART' } | { type: 'CLOSE_CART' }; interface CartContextType { items: CartItem[]; isOpen: boolean; totalItems: number; totalPrice: number; addToCart: (item: Omit) => void; removeFromCart: (id: string) => void; updateQuantity: (id: string, quantity: number) => void; clearCart: () => void; toggleCart: () => void; openCart: () => void; closeCart: () => void; } const CartContext = createContext(undefined); /** * THE REDUCER — Single Source of Truth for All Cart Mutations * * This is a classic Redux-style reducer: pure, predictable, easy to test. * * DESIGN DECISIONS: * - ADD_ITEM automatically increments quantity if the dish already exists * (very common restaurant UX — user taps the same samosa twice) * - UPDATE_QUANTITY with quantity <= 0 removes the item (no negative quantities) * - Only the drawer can toggle isOpen. Menu cards only ever call addToCart(). * - CLEAR_CART is exposed for the "Clear basket" buttons in the drawer header/footer. */ function cartReducer(state: CartState, action: CartAction): CartState { switch (action.type) { case 'ADD_ITEM': { const existingItem = state.items.find(item => item.id === action.payload.id); if (existingItem) { // Increment existing line item (most common case after first add) return { ...state, items: state.items.map(item => item.id === action.payload.id ? { ...item, quantity: item.quantity + 1 } : item ), }; } else { // Brand new line item return { ...state, items: [...state.items, { ...action.payload, quantity: 1 }], }; } } case 'REMOVE_ITEM': return { ...state, items: state.items.filter(item => item.id !== action.payload), }; case 'UPDATE_QUANTITY': if (action.payload.quantity <= 0) { // Treat zero or negative as "remove this line" return { ...state, items: state.items.filter(item => item.id !== action.payload.id), }; } return { ...state, items: state.items.map(item => item.id === action.payload.id ? { ...item, quantity: action.payload.quantity } : item ), }; case 'CLEAR_CART': return { ...state, items: [] }; case 'TOGGLE_CART': return { ...state, isOpen: !state.isOpen }; case 'OPEN_CART': return { ...state, isOpen: true }; case 'CLOSE_CART': return { ...state, isOpen: false }; default: return state; } } /** * PERSISTENCE LAYER * * We use a single well-namespaced localStorage key so the cart survives: * - Page refresh * - Browser restart * - User navigating away and coming back days later * * The restore logic is intentionally verbose (multiple dispatches) because we want * the reducer to remain the only place that understands "how to add an item". * This keeps the restore path debuggable and future-proof. */ const CART_STORAGE_KEY = 'shahi-kitchen-cart'; export function CartProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(cartReducer, { items: [], isOpen: false, }); // ONE-TIME HYDRATION FROM LOCALSTORAGE // Runs only on initial mount. Restores previous session's cart silently (no toasts). useEffect(() => { const savedCart = localStorage.getItem(CART_STORAGE_KEY); if (savedCart) { try { const parsedItems = JSON.parse(savedCart); // We replay through the reducer so all business rules stay in one place parsedItems.forEach((item: CartItem) => { dispatch({ type: 'ADD_ITEM', payload: item }); // If the saved quantity was > 1 we need a second dispatch to correct it if (item.quantity > 1) { dispatch({ type: 'UPDATE_QUANTITY', payload: { id: item.id, quantity: item.quantity } }); } }); } catch (error) { console.error('Failed to load cart from localStorage'); } } }, []); // AUTO-PERSIST ON EVERY CHANGE // Extremely simple and reliable. In a future real ordering system you would // debounce this and also sync to a backend. useEffect(() => { localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(state.items)); }, [state.items]); /** * DERIVED STATE (recomputed on every render — cheap and always fresh) */ const totalItems = state.items.reduce((sum, item) => sum + item.quantity, 0); const totalPrice = state.items.reduce((sum, item) => sum + item.price * item.quantity, 0); /** * PUBLIC API — these functions are what the rest of the app calls. * * addToCart is special: it also triggers the beautiful Sonner toast with * an action button that opens the drawer. This is the primary feedback mechanism. */ const addToCart = (item: Omit) => { dispatch({ type: 'ADD_ITEM', payload: item }); toast.success(`Added ${item.name} to cart`, { description: `${item.price} kr`, action: { label: "View Cart", onClick: () => dispatch({ type: 'OPEN_CART' }), }, }); }; const removeFromCart = (id: string) => { dispatch({ type: 'REMOVE_ITEM', payload: id }); }; const updateQuantity = (id: string, quantity: number) => { dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }); }; const clearCart = () => { dispatch({ type: 'CLEAR_CART' }); }; const toggleCart = () => { dispatch({ type: 'TOGGLE_CART' }); }; const openCart = () => { dispatch({ type: 'OPEN_CART' }); }; const closeCart = () => { dispatch({ type: 'CLOSE_CART' }); }; /** * CONTEXT PROVIDER VALUE * Everything a consumer might need is exposed here. * We intentionally do NOT expose dispatch directly — all mutations go through * the named methods above (better encapsulation + future-proofing). */ return ( {children} ); } /** * useCart — The only way the rest of the app should consume cart state/actions. * * Throws a clear error if someone tries to use it outside the provider tree * (catches 99% of integration mistakes during development). */ export function useCart() { const context = useContext(CartContext); if (context === undefined) { throw new Error('useCart must be used within a CartProvider'); } return context; }