Initial commit: Shahi Kitchen premium website
- 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
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
'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;
|
||||
}
|
||||
Reference in New Issue
Block a user