"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { Bike, CheckCircle2, ChevronRight, MapPin, ShoppingBasket, Store, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { BasketPanel, TipSelector, TotalsList } from "./basket-panel";
import { CheckoutView } from "./checkout-view";
import { MENU_CATEGORIES, MENU_ITEMS, PICKUP_SLOTS, RESTAURANT, TIP_PRESETS } from "./data";
import { dishBg, dishStyle, FoodArt } from "./food-art";
import { basketTotals, BrandMark, focusRing, lineKeyOf, optionSummary, Segmented, unitPrice } from "./food-ui";
import { ItemModal, type ModalState, useFocusTrap } from "./item-modal";
import { MenuView } from "./menu-view";
import { TrackerView } from "./tracker-view";
import type { BasketLine, DeliveryAddress, FulfilmentMode, MenuCategory, MenuItem, OrderStatus, PlacedOrder, Restaurant } from "./types";
export type { BasketLine, DeliveryAddress, FulfilmentMode, MenuCategory, MenuItem, OrderStatus, PlacedOrder, Restaurant };
export interface FoodOrderingAppProps {
restaurant?: Restaurant;
categories?: MenuCategory[];
items?: MenuItem[];
currency?: string;
locale?: string;
initialBasket?: BasketLine[];
initialMode?: FulfilmentMode;
tipPresets?: number[];
pickupSlots?: string[];
defaultAddress?: Partial<DeliveryAddress>;
/** How long the simulated order takes from "received" to "delivered", in ms. */
trackingDurationMs?: number;
onAddToCart?: (line: BasketLine, item: MenuItem) => void;
onBasketChange?: (lines: BasketLine[]) => void;
onCheckout?: (lines: BasketLine[]) => void;
onOrder?: (order: PlacedOrder) => void;
onStatusChange?: (status: OrderStatus, order: PlacedOrder) => void;
className?: string;
}
type View = "menu" | "checkout" | "tracking";
let seq = 0;
function subscribeLg(cb: () => void) {
const mq = window.matchMedia("(min-width: 1024px)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
/** True on wide screens, where the basket is a persistent sidebar instead of a sheet. */
function useIsWide() {
return React.useSyncExternalStore(
subscribeLg,
() => window.matchMedia("(min-width: 1024px)").matches,
() => false,
);
}
export function FoodOrderingApp({
restaurant = RESTAURANT,
categories = MENU_CATEGORIES,
items = MENU_ITEMS,
currency = "EUR",
locale = "en-IE",
initialBasket,
initialMode = "delivery",
tipPresets = TIP_PRESETS,
pickupSlots = PICKUP_SLOTS,
defaultAddress,
trackingDurationMs = 60_000,
onAddToCart,
onBasketChange,
onCheckout,
onOrder,
onStatusChange,
className,
}: FoodOrderingAppProps) {
const [view, setView] = React.useState<View>("menu");
const [mode, setMode] = React.useState<FulfilmentMode>(initialMode);
const [lines, setLines] = React.useState<BasketLine[]>(initialBasket ?? []);
const [tipRate, setTipRate] = React.useState<number | null>(tipPresets[2] ?? 0.15);
const [customTip, setCustomTip] = React.useState(0);
const [modal, setModal] = React.useState<ModalState>(null);
const [sheet, setSheet] = React.useState(false);
const [order, setOrder] = React.useState<PlacedOrder | null>(null);
const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
const [bump, setBump] = React.useState(0);
const sheetRef = React.useRef<HTMLDivElement>(null);
const wide = useIsWide();
const money = React.useMemo(() => {
const f = new Intl.NumberFormat(locale, { style: "currency", currency });
return (n: number) => f.format(n);
}, [locale, currency]);
const byId = React.useMemo(() => Object.fromEntries(items.map((i) => [i.id, i])), [items]);
const totals = basketTotals(lines, byId, restaurant, mode, tipRate, customTip);
const qtyByItem = React.useMemo(() => {
const m: Record<string, number> = {};
for (const l of lines) m[l.itemId] = (m[l.itemId] ?? 0) + l.qty;
return m;
}, [lines]);
const cbRef = React.useRef(onBasketChange);
React.useLayoutEffect(() => {
cbRef.current = onBasketChange;
});
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
cbRef.current?.(lines);
}, [lines]);
const showToast = (text: string) => {
const id = ++seq;
setToast({ id, text });
window.setTimeout(() => setToast((t) => (t?.id === id ? null : t)), 2600);
};
const closeModal = React.useCallback(() => setModal(null), []);
const closeSheet = React.useCallback(() => setSheet(false), []);
useFocusTrap(sheet, sheetRef, closeSheet);
const submitItem = ({ item, selections, qty, notes, editKey }: { item: MenuItem; selections: Record<string, string[]>; qty: number; notes: string; editKey?: string }) => {
const key = lineKeyOf(item.id, selections, notes);
const line: BasketLine = { key, itemId: item.id, qty, selections, notes: notes.trim() || undefined };
setLines((ls) => {
const base = editKey ? ls.filter((l) => l.key !== editKey) : ls;
const existing = base.find((l) => l.key === key);
if (existing) return base.map((l) => (l.key === key ? { ...l, qty: editKey ? qty : l.qty + qty } : l));
if (editKey) {
const idx = ls.findIndex((l) => l.key === editKey);
const next = [...base];
next.splice(Math.max(0, idx), 0, line);
return next;
}
return [...base, line];
});
if (!editKey) onAddToCart?.(line, item);
setModal(null);
setBump((b) => b + 1);
showToast(editKey ? `Updated ${item.name}` : `Added ${qty > 1 ? `${qty}× ` : ""}${item.name}`);
};
const setQty = (key: string, qty: number) => setLines((ls) => (qty <= 0 ? ls.filter((l) => l.key !== key) : ls.map((l) => (l.key === key ? { ...l, qty } : l))));
const goCheckout = () => {
if (!lines.length || totals.belowMin) return;
setSheet(false);
onCheckout?.(lines);
setView("checkout");
};
const place = ({ address, payment, pickupSlot }: { address: DeliveryAddress; payment: string; pickupSlot?: string }) => {
const placedAt = Date.now();
const o: PlacedOrder = {
id: `LK${(placedAt % 100000).toString().padStart(5, "0")}`,
placedAt,
mode,
lines: lines.flatMap((l) => {
const it = byId[l.itemId];
return it ? [{ name: it.name, qty: l.qty, options: optionSummary(it, l.selections), notes: l.notes, total: unitPrice(it, l.selections) * l.qty }] : [];
}),
subtotal: totals.subtotal,
deliveryFee: totals.deliveryFee,
serviceFee: totals.serviceFee,
tip: totals.tip,
total: totals.total,
address,
pickupSlot,
payment,
durationMs: trackingDurationMs,
};
setOrder(o);
setLines([]);
onOrder?.(o);
setView("tracking");
};
const modeToggle = (id: string, className?: string) => (
<Segmented
id={id}
label="Delivery or pickup"
value={mode}
onChange={setMode}
className={className}
options={[
{
id: "delivery",
label: (
<>
<Bike className="size-4" /> Delivery
</>
),
},
{
id: "pickup",
label: (
<>
<Store className="size-4" /> Pickup
</>
),
},
]}
/>
);
const tip = (
<TipSelector
presets={tipPresets}
rate={tipRate}
custom={customTip}
subtotal={totals.subtotal}
money={money}
mode={mode}
onChange={(r, c) => {
setTipRate(r);
setCustomTip(c);
}}
/>
);
const basket = (
<BasketPanel
lines={lines}
byId={byId}
totals={totals}
mode={mode}
modeToggle={null}
money={money}
freeFrom={restaurant.freeDeliveryFrom}
minOrder={restaurant.minOrder}
tip={tip}
onQty={setQty}
onEdit={(l) => {
const it = byId[l.itemId];
if (it) setModal({ item: it, selections: l.selections, qty: l.qty, notes: l.notes, editKey: l.key });
}}
onCheckout={goCheckout}
/>
);
const summary = (
<div className="space-y-4">
<h2 className="text-base font-semibold">Your order</h2>
<ul className="space-y-2.5">
{lines.map((l) => {
const it = byId[l.itemId];
if (!it) return null;
const opts = optionSummary(it, l.selections);
return (
<li key={l.key} className="flex items-center gap-3">
<span className={cn("grid size-11 shrink-0 place-items-center rounded-lg", dishBg)} style={dishStyle(it.hue)}>
<FoodArt kind={it.art} colors={it.colors} />
</span>
<span className="min-w-0 flex-1 text-sm">
<span className="block truncate font-medium">
{l.qty}× {it.name}
</span>
{opts.length > 0 && <span className="block truncate text-xs text-muted-foreground">{opts.join(" · ")}</span>}
</span>
<span className="text-sm tabular-nums">{money(unitPrice(it, l.selections) * l.qty)}</span>
</li>
);
})}
</ul>
<div className="border-t pt-4">{tip}</div>
<div className="border-t pt-4">
<TotalsList totals={totals} money={money} mode={mode} />
</div>
</div>
);
const overlay = Boolean(modal) || (sheet && !wide);
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative isolate flex h-[760px] w-full flex-col overflow-clip bg-background text-foreground antialiased", className)}>
<div inert={overlay ? true : undefined} className="flex min-h-0 flex-1 flex-col">
{/* top bar */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-4 sm:px-6">
<button type="button" onClick={() => setView(order && view === "tracking" ? "tracking" : "menu")} className={cn("flex items-center gap-2 rounded-lg", focusRing)} aria-label={`${restaurant.name} menu`}>
<BrandMark />
<span className="hidden text-[15px] font-bold tracking-tight sm:inline">{restaurant.name}</span>
</button>
<span className="mx-1 hidden h-5 w-px bg-border sm:block" aria-hidden />
<p className="flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground">
<MapPin className="size-4 shrink-0 text-orange-500" />
<span className="truncate">{mode === "delivery" ? `Delivering to ${defaultAddress?.street ?? "42 Merrion Square"}` : `Pickup at ${restaurant.address}`}</span>
</p>
<div className="ml-auto flex items-center gap-2">
{order && view !== "tracking" && (
<button type="button" onClick={() => setView("tracking")} className={cn("hidden h-9 items-center gap-1.5 rounded-full bg-orange-500/10 px-3 text-xs font-semibold text-orange-700 sm:inline-flex dark:text-orange-300", focusRing)}>
<span className="size-1.5 animate-pulse rounded-full bg-orange-500" /> Track order <ChevronRight className="size-3.5" />
</button>
)}
{view === "menu" && (
<button
type="button"
onClick={() => setSheet(true)}
aria-label={`Open basket, ${totals.count} ${totals.count === 1 ? "item" : "items"}`}
className={cn("relative grid size-10 place-items-center rounded-full hover:bg-accent lg:hidden", focusRing)}
>
<motion.span key={bump} animate={bump ? { scale: [1, 1.3, 1], rotate: [0, -10, 0] } : undefined} transition={{ duration: 0.45 }}>
<ShoppingBasket className="size-5" />
</motion.span>
{totals.count > 0 && (
<span className="absolute -right-0.5 -top-0.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-gradient-to-r from-orange-500 to-rose-500 px-1 text-[10px] font-bold text-white ring-2 ring-background">
{totals.count}
</span>
)}
</button>
)}
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<motion.main key={view} className="min-h-0 min-w-0 flex-1" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2 }}>
{view === "menu" && (
<MenuView
restaurant={restaurant}
categories={categories}
items={items}
qtyByItem={qtyByItem}
money={money}
modeToggle={modeToggle("lumen-mode-menu", "w-full sm:w-auto lg:hidden")}
onOpenItem={(item) => setModal({ item })}
bottomPad={totals.count > 0}
/>
)}
{view === "checkout" && (
<CheckoutView
mode={mode}
restaurant={restaurant}
slots={pickupSlots}
summary={summary}
total={money(totals.total)}
modeToggle={modeToggle("lumen-mode-checkout")}
defaultAddress={defaultAddress}
onBack={() => setView("menu")}
onPlace={place}
/>
)}
{view === "tracking" && order && (
<TrackerView
key={order.id}
order={order}
restaurant={restaurant}
money={money}
onStatusChange={(s) => onStatusChange?.(s, order)}
onNewOrder={() => setView("menu")}
/>
)}
</motion.main>
{view === "menu" && wide && (
<aside aria-labelledby="lumen-basket-title" className="hidden w-[360px] shrink-0 border-l bg-muted/20 lg:flex lg:flex-col">
<div className="border-b px-5 pb-3 pt-4">{modeToggle("lumen-mode-side", "w-full")}</div>
<div className="min-h-0 flex-1">{basket}</div>
</aside>
)}
{/* mobile basket bar */}
<AnimatePresence>
{view === "menu" && totals.count > 0 && (
<motion.div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 p-4 lg:hidden" initial={{ y: 100 }} animate={{ y: 0 }} exit={{ y: 100 }} transition={{ type: "spring", stiffness: 380, damping: 34 }}>
<button
type="button"
onClick={() => setSheet(true)}
className={cn("pointer-events-auto flex h-14 w-full items-center gap-3 rounded-2xl bg-gradient-to-r from-orange-500 to-rose-500 px-4 text-white shadow-xl shadow-rose-500/30", focusRing)}
>
<motion.span key={bump} initial={{ scale: 0.6 }} animate={{ scale: 1 }} className="grid h-8 min-w-8 place-items-center rounded-lg bg-white/20 px-1.5 text-sm font-bold tabular-nums">
{totals.count}
</motion.span>
<span className="flex-1 text-left text-sm font-semibold">View basket</span>
<span className="text-sm font-bold tabular-nums">{money(totals.subtotal)}</span>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* mobile basket sheet */}
<AnimatePresence>
{sheet && !wide && (
<>
<motion.div aria-hidden className="absolute inset-0 z-40 bg-black/45" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={closeSheet} />
<motion.div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby="lumen-basket-title"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 380, damping: 40 }}
className="absolute inset-x-0 bottom-0 z-50 flex h-[88%] flex-col overflow-hidden rounded-t-3xl border-t bg-background shadow-2xl sm:inset-x-auto sm:right-0 sm:top-0 sm:h-full sm:w-[400px] sm:rounded-none sm:border-l"
>
<button type="button" data-autofocus onClick={closeSheet} aria-label="Close basket" className={cn("absolute right-3 top-3 z-10 grid size-9 place-items-center rounded-full hover:bg-accent", focusRing)}>
<X className="size-5" />
</button>
<div className="border-b px-5 pb-3 pt-14">{modeToggle("lumen-mode-sheet", "w-full")}</div>
<div className="min-h-0 flex-1">{basket}</div>
</motion.div>
</>
)}
</AnimatePresence>
<ItemModal state={modal} money={money} onClose={closeModal} onSubmit={submitItem} />
<div aria-live="polite" className="pointer-events-none absolute inset-x-0 top-16 z-[60] flex justify-center px-4">
<AnimatePresence>
{toast && (
<motion.div
key={toast.id}
role="status"
initial={{ opacity: 0, y: -16, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="flex items-center gap-2 rounded-full bg-foreground px-4 py-2 text-[13px] font-medium text-background shadow-xl"
>
<CheckCircle2 className="size-4 text-emerald-400 dark:text-emerald-600" /> {toast.text}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</MotionConfig>
);
}
export default FoodOrderingApp;