56fe68eb48
- Royal cream + gold theme - Playful animated hero with chef mascot - Advanced menu with sidebar + video hover - Multilingual support (EN, SV, HI, UR) - Cart system with WhatsApp ordering - Real restaurant photos integration - Responsive design with proper navbar
311 lines
9.8 KiB
TypeScript
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/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<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;
|
|
}
|