"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { CheckCircle2, Heart, ShoppingBag, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { BottleArt } from "./bottle-art";
import { CartDrawer } from "./cart-drawer";
import { CatalogView } from "./catalog-view";
import { CheckoutView } from "./checkout-view";
import { CATEGORIES, COUNTRIES, DELIVERY_METHODS, FREE_SHIPPING_FROM, PRODUCTS, PROMO_CODES } from "./data";
import { Header, MobileNav, MobileSearchSheet } from "./header";
import { Footer, HomeView } from "./home-view";
import { OrderConfirmation } from "./order-confirmation";
import { ProductCard } from "./product-card";
import { ProductView } from "./product-view";
import { focusRing, lineKey, Logo, resolveLines, type StoreApi, StoreContext } from "./store-ui";
import type { CartLine, Category, DeliveryMethod, Order, Product, PromoCode, Route, ShippingDetails } from "./types";
export type { CartLine, Category, DeliveryMethod, Order, Product, PromoCode, Route, ShippingDetails };
export interface OnlineStoreAppProps {
/** Catalogue. Defaults to 24 seeded Northwind Cellar products. */
products?: Product[];
categories?: Category[];
promoCodes?: PromoCode[];
deliveryMethods?: DeliveryMethod[];
/** Countries offered in the shipping form. */
countries?: string[];
/** ISO currency and locale used for all prices. */
currency?: string;
locale?: string;
/** Subtotal at which standard delivery becomes free. */
freeShippingFrom?: number;
/** Show the 18+ age-verification modal on first view. */
ageGate?: boolean;
initialCart?: CartLine[];
initialWishlist?: string[];
/** Initial view — handy for deep links. */
initialRoute?: Route;
/** Prefill for the checkout contact form. */
defaultCustomer?: Partial<ShippingDetails>;
onAddToCart?: (line: CartLine, product: Product) => void;
onCartChange?: (lines: CartLine[]) => void;
onWishlistChange?: (productIds: string[]) => void;
onCheckout?: (lines: CartLine[]) => void;
/** Called when an order is placed (after the demo payment step). */
onOrder?: (order: Order) => void;
className?: string;
}
type Flyer = { id: number; x0: number; y0: number; x1: number; y1: number; product: Product; liquid: string };
type Toast = { id: number; text: string; action?: { label: string; run: () => void }; icon?: "bag" | "heart" };
let seq = 0;
function routeKey(r: Route) {
if (r.name === "catalog") return `catalog:${r.category ?? ""}:${r.query ?? ""}`;
if (r.name === "product") return `product:${r.id}`;
if (r.name === "confirmation") return `confirmation:${r.order.number}`;
return r.name;
}
function WishlistView() {
const ctx = React.useContext(StoreContext);
if (!ctx) return null;
const list = ctx.products.filter((p) => ctx.wishlist.has(p.id));
return (
<div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6">
<h1 className="font-serif text-3xl tracking-tight sm:text-4xl">Saved for later</h1>
<p className="mt-1 text-sm text-muted-foreground">{list.length ? `${list.length} bottles on your wishlist.` : "Tap the heart on any bottle to keep it here."}</p>
{list.length === 0 ? (
<div className="mt-8 grid place-items-center rounded-3xl border border-dashed px-6 py-16 text-center">
<Heart className="size-10 text-muted-foreground/60" />
<p className="mt-4 font-serif text-xl">Your wishlist is empty</p>
<button type="button" onClick={() => ctx.go({ name: "catalog" })} className={cn("mt-4 h-10 rounded-full bg-foreground px-5 text-sm font-semibold text-background", focusRing)}>
Browse the cellar
</button>
</div>
) : (
<ul className="mt-6 grid grid-cols-2 gap-x-4 gap-y-7 md:grid-cols-3 lg:grid-cols-4">
<AnimatePresence initial={false} mode="popLayout">
{list.map((p) => (
<motion.li key={p.id} layout initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }}>
<ProductCard product={p} />
</motion.li>
))}
</AnimatePresence>
</ul>
)}
</div>
);
}
function AgeGate({ onConfirm }: { onConfirm: () => void }) {
const [denied, setDenied] = React.useState(false);
const yesRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
const t = window.setTimeout(() => yesRef.current?.focus(), 80);
return () => window.clearTimeout(t);
}, []);
return (
<motion.div className="absolute inset-0 z-[80] grid place-items-center bg-neutral-950/55 p-4 backdrop-blur-md" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.3 } }}>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="nw-age-title"
aria-describedby="nw-age-desc"
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ type: "spring", stiffness: 300, damping: 28 }}
className="relative w-full max-w-sm overflow-hidden rounded-3xl border bg-background p-7 text-center shadow-2xl"
onKeyDown={(e) => {
if (e.key !== "Tab") return;
const els = e.currentTarget.querySelectorAll<HTMLElement>("button");
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}}
>
<div aria-hidden className="absolute inset-x-0 top-0 h-28 bg-gradient-to-b from-amber-400/20 to-transparent" />
<div className="relative flex justify-center">
<Logo />
</div>
<AnimatePresence mode="wait" initial={false}>
{denied ? (
<motion.div key="no" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="relative">
<h2 id="nw-age-title" className="mt-6 font-serif text-2xl">Sorry, not just yet</h2>
<p id="nw-age-desc" className="mt-2 text-sm text-muted-foreground">
You need to be of legal drinking age to visit Northwind Cellar. We’ll be here when you are.
</p>
<button type="button" onClick={() => setDenied(false)} className={cn("mt-6 h-11 w-full rounded-full border text-sm font-semibold", focusRing)}>
I entered the wrong answer
</button>
</motion.div>
) : (
<motion.div key="ask" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="relative">
<span className="mx-auto mt-6 grid size-14 place-items-center rounded-full border-2 border-foreground text-lg font-bold">18+</span>
<h2 id="nw-age-title" className="mt-4 font-serif text-2xl">Are you of legal drinking age?</h2>
<p id="nw-age-desc" className="mt-2 text-sm text-muted-foreground">
Please confirm you are 18 or older to enter. We check ID on every delivery.
</p>
<div className="mt-6 grid gap-2.5">
<button ref={yesRef} type="button" onClick={onConfirm} className={cn("h-11 rounded-full bg-foreground text-sm font-semibold text-background transition hover:opacity-90", focusRing)}>
Yes, I’m 18 or older
</button>
<button type="button" onClick={() => setDenied(true)} className={cn("h-11 rounded-full border text-sm font-semibold transition hover:bg-accent", focusRing)}>
No, I’m under 18
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<p className="relative mt-5 text-[11px] text-muted-foreground">Enjoy responsibly.</p>
</motion.div>
</motion.div>
);
}
export function OnlineStoreApp({
products = PRODUCTS,
categories = CATEGORIES,
promoCodes = PROMO_CODES,
deliveryMethods = DELIVERY_METHODS,
countries = COUNTRIES,
currency = "EUR",
locale = "en-IE",
freeShippingFrom = FREE_SHIPPING_FROM,
ageGate = true,
initialCart,
initialWishlist,
initialRoute,
defaultCustomer,
onAddToCart,
onCartChange,
onWishlistChange,
onCheckout,
onOrder,
className,
}: OnlineStoreAppProps) {
const reduce = useReducedMotion();
const [route, setRoute] = React.useState<Route>(initialRoute ?? { name: "home" });
const [cart, setCart] = React.useState<CartLine[]>(initialCart ?? []);
const [wishlist, setWishlist] = React.useState<Set<string>>(() => new Set(initialWishlist ?? ["kinmori-pure-malt", "maison-aurele-brut"]));
const [cartOpen, setCartOpen] = React.useState(false);
const [searchOpen, setSearchOpen] = React.useState(false);
const [promo, setPromo] = React.useState<PromoCode | null>(null);
const [ageOk, setAgeOk] = React.useState(!ageGate);
const [checkoutAt, setCheckoutAt] = React.useState<Date | null>(null);
const [bump, setBump] = React.useState(0);
const [flyers, setFlyers] = React.useState<Flyer[]>([]);
const [toasts, setToasts] = React.useState<Toast[]>([]);
const rootRef = React.useRef<HTMLDivElement>(null);
const scrollRef = React.useRef<HTMLDivElement>(null);
const cartRef = React.useRef<HTMLButtonElement>(null);
const money = React.useMemo(() => {
const f = new Intl.NumberFormat(locale, { style: "currency", currency });
return (n: number) => f.format(n);
}, [currency, locale]);
const byId = React.useMemo(() => Object.fromEntries(products.map((p) => [p.id, p])), [products]);
const catNames = React.useMemo(() => Object.fromEntries(categories.map((c) => [c.id, c.name])), [categories]);
const categoryName = React.useCallback((id: string) => catNames[id] ?? id, [catNames]);
const lines = React.useMemo(() => resolveLines(cart, byId), [cart, byId]);
const cartCount = cart.reduce((s, l) => s + l.qty, 0);
/* ----------------------------- callbacks ----------------------------- */
const cbRef = React.useRef({ onCartChange, onWishlistChange });
React.useLayoutEffect(() => {
cbRef.current = { onCartChange, onWishlistChange };
});
const firstCart = React.useRef(true);
React.useEffect(() => {
if (firstCart.current) {
firstCart.current = false;
return;
}
cbRef.current.onCartChange?.(cart);
}, [cart]);
const firstWish = React.useRef(true);
React.useEffect(() => {
if (firstWish.current) {
firstWish.current = false;
return;
}
cbRef.current.onWishlistChange?.([...wishlist]);
}, [wishlist]);
const toast = React.useCallback((t: Omit<Toast, "id">) => {
const id = ++seq;
setToasts((ts) => [...ts.slice(-1), { ...t, id }]);
window.setTimeout(() => setToasts((ts) => ts.filter((x) => x.id !== id)), 3800);
}, []);
const go = React.useCallback((r: Route) => {
setRoute(r);
setSearchOpen(false);
scrollRef.current?.scrollTo({ top: 0 });
}, []);
const toggleWish = React.useCallback(
(id: string) => {
setWishlist((w) => {
const n = new Set(w);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
if (!wishlist.has(id)) toast({ text: `Saved ${byId[id]?.name ?? "item"} to your wishlist`, icon: "heart" });
},
[wishlist, toast, byId],
);
const addToCart = React.useCallback<StoreApi["addToCart"]>(
(p, opts = {}) => {
if (p.stock <= 0) return;
const editionId = opts.editionId ?? p.editions[0].id;
const sizeId = opts.sizeId ?? (p.sizes.find((s) => s.factor === 1) ?? p.sizes[0]).id;
const qty = opts.qty ?? 1;
const key = lineKey(p.id, editionId, sizeId);
const line: CartLine = { key, productId: p.id, editionId, sizeId, qty };
setCart((c) => {
const found = c.find((l) => l.key === key);
if (found) return c.map((l) => (l.key === key ? { ...l, qty: Math.min(Math.max(1, p.stock), l.qty + qty) } : l));
return [...c, line];
});
onAddToCart?.(line, p);
const root = rootRef.current?.getBoundingClientRect();
const from = opts.from?.getBoundingClientRect();
const to = cartRef.current?.getBoundingClientRect();
const liquid = p.editions.find((e) => e.id === editionId)?.liquid ?? p.editions[0].liquid;
if (root && from && to && !reduce) {
const size = 56;
setFlyers((f) => [
...f,
{
id: ++seq,
product: p,
liquid,
x0: from.left - root.left + from.width / 2 - size / 2,
y0: from.top - root.top + from.height / 2 - size,
x1: to.left - root.left + to.width / 2 - size / 2,
y1: to.top - root.top + to.height / 2 - size / 2,
},
]);
} else {
setBump((b) => b + 1);
}
toast({ text: `${qty > 1 ? `${qty} × ` : ""}${p.name} added to your bag`, icon: "bag", action: { label: "View bag", run: () => setCartOpen(true) } });
},
[onAddToCart, reduce, toast],
);
const api = React.useMemo<StoreApi>(
() => ({ money, products, byId, categories, categoryName, wishlist, toggleWish, go, addToCart, freeShippingFrom }),
[money, products, byId, categories, categoryName, wishlist, toggleWish, go, addToCart, freeShippingFrom],
);
const startCheckout = () => {
if (!cart.length) return;
setCartOpen(false);
setCheckoutAt(new Date());
onCheckout?.(cart);
go({ name: "checkout" });
};
const placeOrder = (order: Order) => {
onOrder?.(order);
setCart([]);
setPromo(null);
go({ name: "confirmation", order });
};
/* ------------------------------ keyboard ----------------------------- */
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (e.key === "/" && !t.closest("input,textarea,select,[contenteditable]")) {
const input = rootRef.current?.querySelector<HTMLInputElement>("header [role=combobox]");
if (input && input.offsetParent !== null) {
e.preventDefault();
input.focus();
}
}
if (e.key === "Escape") setSearchOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
/* ------------------------------- render ------------------------------ */
const inCheckout = route.name === "checkout" || route.name === "confirmation";
const overlay = cartOpen || searchOpen || !ageOk;
let page: React.ReactNode;
if (route.name === "home") page = <HomeView />;
else if (route.name === "catalog") page = <CatalogView key={routeKey(route)} category={route.category} query={route.query} />;
else if (route.name === "product") page = byId[route.id] ? <ProductView key={route.id} product={byId[route.id]} /> : <HomeView />;
else if (route.name === "wishlist") page = <WishlistView />;
else if (route.name === "checkout")
page =
lines.length === 0 ? (
<div className="grid place-items-center px-6 py-24 text-center">
<ShoppingBag className="size-10 text-muted-foreground" />
<p className="mt-4 font-serif text-2xl">Your bag is empty</p>
<button type="button" onClick={() => go({ name: "catalog" })} className={cn("mt-4 h-10 rounded-full bg-foreground px-5 text-sm font-semibold text-background", focusRing)}>
Browse the cellar
</button>
</div>
) : (
<CheckoutView
lines={lines}
promo={promo}
codes={promoCodes}
methods={deliveryMethods}
countries={countries}
now={checkoutAt ?? new Date(0)}
defaultDetails={defaultCustomer}
onApplyPromo={setPromo}
onRemovePromo={() => setPromo(null)}
onBack={() => go({ name: "home" })}
onPlaced={placeOrder}
/>
);
else page = <OrderConfirmation order={route.order} onContinue={() => go({ name: "home" })} />;
return (
<StoreContext.Provider value={api}>
<MotionConfig reducedMotion="user">
<div ref={rootRef} 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">
<Header
route={route}
cartCount={cartCount}
wishCount={wishlist.size}
cartRef={cartRef}
cartBump={bump}
onOpenCart={() => setCartOpen(true)}
onOpenSearch={() => setSearchOpen(true)}
compact={route.name === "checkout"}
/>
<div ref={scrollRef} data-scroll className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain">
<motion.main key={routeKey(route)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.22 }} className="pb-10">
{page}
</motion.main>
{!inCheckout && <Footer />}
</div>
{!inCheckout && <MobileNav route={route} cartCount={cartCount} wishCount={wishlist.size} onOpenCart={() => setCartOpen(true)} onOpenSearch={() => setSearchOpen(true)} />}
</div>
<MobileSearchSheet open={searchOpen} onClose={() => setSearchOpen(false)} />
<CartDrawer
open={cartOpen}
lines={lines}
promo={promo}
codes={promoCodes}
onClose={() => setCartOpen(false)}
onQty={(key, qty) => setCart((c) => c.map((l) => (l.key === key ? { ...l, qty } : l)))}
onRemove={(key) => {
const removed = cart.find((l) => l.key === key);
const idx = cart.findIndex((l) => l.key === key);
setCart((c) => c.filter((l) => l.key !== key));
if (removed)
toast({
text: `Removed ${byId[removed.productId]?.name ?? "item"}`,
action: {
label: "Undo",
run: () =>
setCart((c) => {
const n = [...c];
n.splice(idx, 0, removed);
return n;
}),
},
});
}}
onApplyPromo={setPromo}
onRemovePromo={() => setPromo(null)}
onCheckout={startCheckout}
/>
{/* fly-to-cart */}
<div aria-hidden className="pointer-events-none absolute inset-0 z-[60]">
{flyers.map((f) => (
<motion.div
key={f.id}
className="absolute left-0 top-0 grid size-14 place-items-center rounded-full bg-background shadow-xl ring-1 ring-border"
initial={{ x: f.x0, y: f.y0, scale: 1, opacity: 0 }}
animate={{ x: [f.x0, (f.x0 + f.x1) / 2, f.x1], y: [f.y0, Math.min(f.y0, f.y1) - 90, f.y1], scale: [1.1, 0.9, 0.3], opacity: [0, 1, 1, 0.6] }}
transition={{ duration: 0.85, ease: [0.45, 0, 0.2, 1], times: [0, 0.45, 1] }}
onAnimationComplete={() => {
setFlyers((fs) => fs.filter((x) => x.id !== f.id));
setBump((b) => b + 1);
}}
>
<span className="h-11 w-6">
<BottleArt product={f.product} liquid={f.liquid} shadow={false} />
</span>
</motion.div>
))}
</div>
{/* toasts */}
<div aria-live="polite" className="pointer-events-none absolute inset-x-0 bottom-20 z-[65] flex flex-col items-center gap-2 px-4 md:bottom-6">
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
role="status"
initial={{ opacity: 0, y: 20, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 500, damping: 32 }}
className="pointer-events-auto flex max-w-full items-center gap-2.5 rounded-full bg-foreground py-2 pl-3.5 pr-2 text-[13px] text-background shadow-xl shadow-black/20"
>
{t.icon === "heart" ? <Heart className="size-4 shrink-0 fill-rose-400 text-rose-400" /> : <CheckCircle2 className="size-4 shrink-0 text-emerald-400 dark:text-emerald-600" />}
<span className="min-w-0 truncate font-medium">{t.text}</span>
{t.action && (
<button
type="button"
onClick={() => {
t.action?.run();
setToasts((ts) => ts.filter((x) => x.id !== t.id));
}}
className="h-7 shrink-0 rounded-full bg-background/15 px-3 text-xs font-semibold outline-none hover:bg-background/25 focus-visible:ring-2 focus-visible:ring-background/60"
>
{t.action.label}
</button>
)}
<button
type="button"
aria-label="Dismiss"
onClick={() => setToasts((ts) => ts.filter((x) => x.id !== t.id))}
className="grid size-7 shrink-0 place-items-center rounded-full text-background/60 outline-none hover:text-background focus-visible:ring-2 focus-visible:ring-background/60"
>
<X className="size-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
<AnimatePresence>{!ageOk && <AgeGate onConfirm={() => setAgeOk(true)} />}</AnimatePresence>
</div>
</MotionConfig>
</StoreContext.Provider>
);
}
export default OnlineStoreApp;