Files
shahikitchen/components/CartContext.tsx
T
Zeeshan Khan 50de4b0c90 fix: update WhatsApp order number to +46 73 938 10 89
- Changed the order WhatsApp from 46709864995 to 46739381089 in CartDrawer.tsx (the wa.me link used for cart orders).
- Updated matching comments in CartDrawer.tsx, CartContext.tsx, and app/layout.tsx.
- This is the number for WhatsApp orders (matches the mobile phone 0739-381089 used for reservations/bookings).
- Phone display numbers (landline + mobile) left unchanged as they were not part of the request.
2026-06-02 16:57:41 +02:00

311 lines
9.8 KiB
TypeScript

'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/46739381089 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<CartItem, 'quantity'> }
| { 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<CartItem, 'quantity'>) => void;
removeFromCart: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
openCart: () => void;
closeCart: () => void;
}
const CartContext = createContext<CartContextType | undefined>(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<CartItem, 'quantity'>) => {
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 (
<CartContext.Provider
value={{
items: state.items,
isOpen: state.isOpen,
totalItems,
totalPrice,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
toggleCart,
openCart,
closeCart,
}}
>
{children}
</CartContext.Provider>
);
}
/**
* 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;
}