"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { AlertCircle, CheckCircle2, Database, Heart, ShoppingBag, X } from "lucide-react";
import type { SupabaseClient } from "@supabase/supabase-js";
import { cn } from "@/lib/utils";
import { AccountView } from "./account-view";
import { AdminView } from "./admin-view";
import { BottleArt } from "./bottle-art";
import { CartDrawer } from "./cart-drawer";
import { CatalogView } from "./catalog-view";
import { CheckoutView } from "./checkout-view";
import { COUNTRIES, FREE_SHIPPING_FROM } from "./data";
import { createDemoBackend } from "./demo-backend";
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 { mergeCarts, type StoreBackend } from "./store-backend";
import { focusRing, lineKey, Logo, priceFor, resolveLines, type StoreApi, StoreContext } from "./store-ui";
import { createSupabaseBackend } from "./supabase-backend";
import type { CartLine, Category, Customer, DeliveryMethod, Order, Product, PromoCode, Route, ShippingDetails } from "./types";
export type { CartLine, Category, Customer, DeliveryMethod, Order, Product, PromoCode, Route, ShippingDetails };
export type { StoreBackend } from "./store-backend";
export { createDemoBackend } from "./demo-backend";
export { createSupabaseBackend } from "./supabase-backend";
export interface OnlineStoreKitProps {
/**
* A Supabase client (`createClient(url, anonKey)`). When given, the shop runs live:
* catalogue, auth, carts and orders come from your project and payments go through Stripe.
*/
supabase?: SupabaseClient;
/** Bring your own data layer instead (takes precedence over `supabase`). */
backend?: StoreBackend;
/** Demo-mode seed data. Ignored in live mode (the database is the source of truth). */
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 (display only — the server decides). */
freeShippingFrom?: number;
/** Show the 18+ age-verification modal on first view. */
ageGate?: boolean;
/** Show the small “Demo mode” badge (with the “View as admin” switch) in demo mode. */
showDemoBadge?: boolean;
initialCart?: CartLine[];
initialWishlist?: string[];
/** Initial view — handy for deep links. */
initialRoute?: Route;
/** Prefill for the checkout contact form. */
defaultCustomer?: Partial<ShippingDetails>;
/** Where Stripe sends the customer back to. Defaults to the current page. */
returnUrl?: string;
onAddToCart?: (line: CartLine, product: Product) => void;
onCartChange?: (lines: CartLine[]) => void;
onWishlistChange?: (productIds: string[]) => void;
onCheckout?: (lines: CartLine[]) => void;
/** Called when an order has been paid (demo) or confirmed after the Stripe redirect (live). */
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" | "ok" | "error" };
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.orderId}`;
if (r.name === "admin") return "admin";
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>
);
}
function DemoBadge({ isAdmin, busy, onToggleAdmin, onDismiss }: { isAdmin: boolean; busy: boolean; onToggleAdmin: () => void; onDismiss: () => void }) {
return (
<motion.div
role="region"
aria-label="Demo mode"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 420, damping: 32, delay: 0.4 }}
className="pointer-events-auto flex items-center gap-2 rounded-full border bg-background/95 py-1.5 pl-2 pr-1.5 text-xs shadow-lg shadow-black/10 backdrop-blur dark:shadow-black/40"
>
<span className="grid size-6 shrink-0 place-items-center rounded-full bg-amber-400/20 text-amber-700 dark:text-amber-300">
<Database className="size-3.5" />
</span>
<span className="font-medium">
Demo mode<span className="hidden text-muted-foreground lg:inline"> — connect Supabase to go live</span>
</span>
<span className="mx-0.5 h-4 w-px bg-border" aria-hidden />
<label className="relative flex cursor-pointer items-center gap-2 rounded-full px-1 font-medium">
<input type="checkbox" role="switch" checked={isAdmin} disabled={busy} onChange={onToggleAdmin} className="peer sr-only" />
<span className="whitespace-nowrap">View as admin</span>
<span
aria-hidden
className={cn(
"relative h-5 w-8 shrink-0 rounded-full transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
isAdmin ? "bg-amber-500" : "bg-foreground/15",
busy && "opacity-60",
)}
>
<motion.span layout transition={{ type: "spring", stiffness: 600, damping: 32 }} className={cn("absolute top-0.5 size-4 rounded-full bg-background shadow", isAdmin ? "right-0.5" : "left-0.5")} />
</span>
</label>
<button type="button" onClick={onDismiss} aria-label="Hide demo badge" className={cn("grid size-6 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground", focusRing)}>
<X className="size-3.5" />
</button>
</motion.div>
);
}
function withParams(base: string, params: Record<string, string>) {
const u = new URL(base);
u.hash = "";
for (const k of ["nw_order", "nw_checkout", "session_id"]) u.searchParams.delete(k);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
return u.toString();
}
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function OnlineStoreKit({
supabase,
backend: backendProp,
products: productsSeed,
categories: categoriesSeed,
promoCodes,
deliveryMethods: deliverySeed,
countries = COUNTRIES,
currency = "EUR",
locale = "en-IE",
freeShippingFrom = FREE_SHIPPING_FROM,
ageGate = true,
showDemoBadge = true,
initialCart,
initialWishlist,
initialRoute,
defaultCustomer,
returnUrl,
onAddToCart,
onCartChange,
onWishlistChange,
onCheckout,
onOrder,
className,
}: OnlineStoreKitProps) {
const reduce = useReducedMotion();
// Created once. Pass a new `key` to the component if you need to swap backends.
const [backend] = React.useState<StoreBackend>(
() =>
backendProp ??
(supabase ? createSupabaseBackend(supabase) : createDemoBackend({ products: productsSeed, categories: categoriesSeed, promoCodes, deliveryMethods: deliverySeed })),
);
const init = backend.initial;
const [products, setProducts] = React.useState<Product[]>(init?.products ?? []);
const [categories, setCategories] = React.useState<Category[]>(init?.categories ?? []);
const [methods, setMethods] = React.useState<DeliveryMethod[]>(init?.deliveryMethods ?? []);
const [loading, setLoading] = React.useState(!init);
const [user, setUser] = React.useState<Customer | null>(null);
const [route, setRoute] = React.useState<Route>(initialRoute ?? { name: "home" });
const [cart, setCart] = React.useState<CartLine[]>(initialCart ?? []);
const [confirmed, setConfirmed] = React.useState<Order | null>(null);
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 [badge, setBadge] = React.useState(showDemoBadge && backend.mode === "demo");
const [roleBusy, setRoleBusy] = React.useState(false);
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 cartBtnRef = React.useRef<HTMLButtonElement>(null);
const userRef = React.useRef<Customer | null>(null);
const cartRef = React.useRef<CartLine[]>(cart);
const cartReady = React.useRef(false);
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, onOrder });
React.useLayoutEffect(() => {
cbRef.current = { onCartChange, onWishlistChange, onOrder };
cartRef.current = cart;
});
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)), 4200);
}, []);
const notify = React.useCallback((text: string, tone: "ok" | "error" = "ok") => toast({ text, icon: tone }), [toast]);
const go = React.useCallback((r: Route) => {
setRoute(r);
setSearchOpen(false);
scrollRef.current?.scrollTo({ top: 0 });
}, []);
/* ------------------------- backend: catalogue ------------------------ */
const refreshCatalog = React.useCallback(async () => {
try {
const [c, p, m] = await Promise.all([backend.catalog.categories(), backend.catalog.products(), backend.catalog.deliveryMethods()]);
setCategories(c);
setProducts(p);
setMethods(m);
} catch {
notify("Couldn’t load the shop. Check your connection and try again.", "error");
} finally {
setLoading(false);
}
}, [backend, notify]);
React.useEffect(() => {
if (init) return;
let alive = true;
Promise.all([backend.catalog.categories(), backend.catalog.products(), backend.catalog.deliveryMethods()])
.then(([c, p, m]) => {
if (!alive) return;
setCategories(c);
setProducts(p);
setMethods(m);
setLoading(false);
})
.catch(() => {
if (!alive) return;
setLoading(false);
notify("Couldn’t load the shop. Check your connection and try again.", "error");
});
return () => {
alive = false;
};
}, [init, backend, notify]);
/* --------------------- backend: auth + cart sync --------------------- */
React.useEffect(() => {
let alive = true;
const adopt = async (u: Customer | null) => {
const prev = userRef.current;
userRef.current = u;
if (!alive) return;
setUser(u);
if (!cartReady.current) return;
if (u && prev?.id !== u.id) {
// Guest → signed in: fold the guest bag into the saved one.
const remote = await backend.cart.load(u).catch((): CartLine[] => []);
if (!alive) return;
setCart(mergeCarts(remote, cartRef.current));
void backend.cart.save(null, []).catch(() => undefined);
} else if (!u && prev) {
setCart([]);
}
};
void (async () => {
const u = await backend.auth.current().catch(() => null);
userRef.current = u;
if (!alive) return;
setUser(u);
if (!initialCart) {
const guest = await backend.cart.load(null).catch((): CartLine[] => []);
let loaded = guest;
if (u) {
const remote = await backend.cart.load(u).catch((): CartLine[] => []);
loaded = mergeCarts(remote, guest);
if (guest.length) void backend.cart.save(null, []).catch(() => undefined);
}
if (!alive) return;
setCart((c) => (c.length ? c : loaded));
}
cartReady.current = true;
})();
const off = backend.auth.onChange((u) => void adopt(u));
return () => {
alive = false;
off();
};
// initialCart is intentionally read once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [backend]);
React.useEffect(() => {
if (!cartReady.current) return;
const t = window.setTimeout(() => {
void backend.cart.save(userRef.current, cart).catch(() => undefined);
}, 350);
return () => window.clearTimeout(t);
}, [cart, backend]);
/* ----------------------- age gate (per session) ---------------------- */
React.useEffect(() => {
if (!ageGate) return;
let ok = false;
try {
ok = window.sessionStorage.getItem("nw-age-ok") === "1";
} catch {
/* storage blocked */
}
// Deferred so the server render and the first client render match.
if (ok) void Promise.resolve().then(() => setAgeOk(true));
}, [ageGate]);
const confirmAge = () => {
setAgeOk(true);
try {
window.sessionStorage.setItem("nw-age-ok", "1");
} catch {
/* storage blocked */
}
};
/* ------------------- Stripe return (?nw_order=…) -------------------- */
React.useEffect(() => {
if (backend.mode !== "live") return;
const params = new URLSearchParams(window.location.search);
const orderId = params.get("nw_order");
const state = params.get("nw_checkout");
if (!orderId) return;
for (const k of ["nw_order", "nw_checkout", "session_id"]) params.delete(k);
const qs = params.toString();
window.history.replaceState(null, "", `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`);
let alive = true;
void (async () => {
await Promise.resolve();
if (!alive) return;
setAgeOk(true);
if (state === "cancelled") {
go({ name: "account" });
notify("Payment cancelled — your order is saved in your account, awaiting payment.");
return;
}
go({ name: "confirmation", orderId });
// The webhook usually lands within a second or two; poll briefly.
for (let i = 0; i < 12 && alive; i++) {
const o = await backend.orders.get(orderId).catch(() => null);
if (!alive) return;
if (o) setConfirmed(o);
if (o && o.status !== "pending") {
cbRef.current.onOrder?.(o);
void refreshCatalog();
return;
}
await wait(1500);
}
})();
return () => {
alive = false;
};
}, [backend, go, notify, refreshCatalog]);
/* ------------------------------- cart -------------------------------- */
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 = {}) => {
const { edition, size, variant, stock } = priceFor(p, opts.editionId, opts.sizeId);
if (stock <= 0) return;
const qty = opts.qty ?? 1;
const key = variant?.id ?? lineKey(p.id, edition.id, size.id);
const line: CartLine = { key, variantId: key, productId: p.id, editionId: edition.id, sizeId: size.id, 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, stock), l.qty + qty) } : l));
return [...c, { ...line, qty: Math.min(qty, stock) }];
});
onAddToCart?.(line, p);
const root = rootRef.current?.getBoundingClientRect();
const from = opts.from?.getBoundingClientRect();
const to = cartBtnRef.current?.getBoundingClientRect();
if (root && from && to && !reduce) {
const sz = 56;
setFlyers((f) => [
...f,
{
id: ++seq,
product: p,
liquid: edition.liquid,
x0: from.left - root.left + from.width / 2 - sz / 2,
y0: from.top - root.top + from.height / 2 - sz,
x1: to.left - root.left + to.width / 2 - sz / 2,
y1: to.top - root.top + to.height / 2 - sz / 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, mode: backend.mode, user, loading, backend, refreshCatalog, notify }),
[money, products, byId, categories, categoryName, wishlist, toggleWish, go, addToCart, freeShippingFrom, backend, user, loading, refreshCatalog, notify],
);
const startCheckout = () => {
if (!cart.length) return;
setCartOpen(false);
setCheckoutAt(new Date());
onCheckout?.(cart);
go({ name: "checkout" });
};
/* ------------------------------ ordering ----------------------------- */
const submitOrder: React.ComponentProps<typeof CheckoutView>["onSubmit"] = async (input, card) => {
const expectedPrices = Object.fromEntries(lines.map((l) => [l.line.variantId, l.unit]));
let placed;
try {
placed = await backend.orders.place({ ...input, lines: cart, expectedPrices });
} catch (e) {
// Prices or stock moved under us — refresh so the bag shows the truth.
void refreshCatalog();
throw e;
}
const base = returnUrl ?? window.location.href;
const res = await backend.orders.pay(placed.orderId, {
successUrl: withParams(base, { nw_checkout: "success" }),
cancelUrl: withParams(base, { nw_checkout: "cancelled" }),
cardLast4: card?.last4,
});
setCart([]);
setPromo(null);
if (res.kind === "redirect") {
window.location.assign(res.url);
return;
}
setConfirmed(res.order);
onOrder?.(res.order);
void refreshCatalog();
go({ name: "confirmation", orderId: res.order.id });
};
const payPending = async (orderId: string) => {
const base = returnUrl ?? window.location.href;
const res = await backend.orders.pay(orderId, { successUrl: withParams(base, { nw_checkout: "success" }), cancelUrl: withParams(base, { nw_checkout: "cancelled" }), cardLast4: "4242" });
if (res.kind === "redirect") window.location.assign(res.url);
else {
setConfirmed(res.order);
go({ name: "confirmation", orderId: res.order.id });
}
};
const buyAgain = (order: Order) => {
let added = 0;
setCart((c) => {
let next = c;
for (const l of order.lines) {
const p = byId[l.productId];
const v = p?.variants?.find((x) => x.id === l.variantId);
if (!p || !v || !v.active || v.stock <= 0) continue;
added += 1;
next = mergeCarts(next, [{ key: v.id, variantId: v.id, productId: p.id, editionId: v.editionId, sizeId: v.sizeId, qty: Math.min(l.qty, v.stock) }]);
}
return next;
});
window.setTimeout(() => {
if (added) setCartOpen(true);
else notify("Those bottles are no longer available.", "error");
}, 0);
};
const toggleAdmin = async () => {
if (!backend.demo) return;
const next = user?.role === "admin" ? "customer" : "admin";
setRoleBusy(true);
try {
await backend.demo.setRole(next);
go(next === "admin" ? { name: "admin" } : route.name === "admin" ? { name: "account" } : route);
notify(next === "admin" ? "You’re viewing the store as an admin" : "Back to the customer view");
} finally {
setRoleBusy(false);
}
};
/* ------------------------------ 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 inAdmin = route.name === "admin";
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]} /> : loading ? <PageSpinner /> : <HomeView />;
else if (route.name === "wishlist") page = <WishlistView />;
else if (route.name === "account") page = <AccountView onPay={payPending} onBuyAgain={buyAgain} onToggleAdmin={backend.demo ? toggleAdmin : undefined} />;
else if (route.name === "admin")
page = <AdminView tab={route.tab} onToggleAdmin={backend.demo ? toggleAdmin : undefined} />;
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}
methods={methods}
countries={countries}
now={checkoutAt ?? new Date(0)}
defaultDetails={defaultCustomer}
onApplyPromo={setPromo}
onRemovePromo={() => setPromo(null)}
onBack={() => go({ name: "home" })}
onSubmit={submitOrder}
/>
);
else
page =
confirmed && confirmed.id === route.orderId ? (
<OrderConfirmation order={confirmed} onContinue={() => go({ name: "home" })} onViewOrders={() => go({ name: "account" })} />
) : (
<PageSpinner label="Confirming your payment…" />
);
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={cartBtnRef}
cartBump={bump}
onOpenCart={() => setCartOpen(true)}
onOpenSearch={() => setSearchOpen(true)}
variant={route.name === "checkout" ? "checkout" : inAdmin ? "admin" : "shop"}
/>
<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={cn("pb-10", badge && !inCheckout && !inAdmin && "pb-24 md:pb-16")}>
{page}
</motion.main>
{!inCheckout && !inAdmin && <Footer />}
</div>
{!inCheckout && !inAdmin && <MobileNav route={route} cartCount={cartCount} onOpenCart={() => setCartOpen(true)} onOpenSearch={() => setSearchOpen(true)} />}
</div>
<MobileSearchSheet open={searchOpen} onClose={() => setSearchOpen(false)} />
<CartDrawer
open={cartOpen}
lines={lines}
promo={promo}
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>
{/* demo badge */}
<div className="pointer-events-none absolute inset-x-0 bottom-[68px] z-[35] flex justify-center px-3 md:inset-x-auto md:bottom-4 md:left-4 md:justify-start md:px-0">
<AnimatePresence>
{badge && backend.mode === "demo" && !inCheckout && !inAdmin && <DemoBadge isAdmin={user?.role === "admin"} busy={roleBusy} onToggleAdmin={() => void toggleAdmin()} onDismiss={() => setBadge(false)} />}
</AnimatePresence>
</div>
{/* toasts */}
<div aria-live="polite" className={cn("pointer-events-none absolute inset-x-0 z-[65] flex flex-col items-center gap-2 px-4 md:bottom-6", badge && !inCheckout && !inAdmin ? "bottom-32 md:bottom-16" : inCheckout || inAdmin ? "bottom-6" : "bottom-20")}>
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
role={t.icon === "error" ? "alert" : "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" />
) : t.icon === "error" ? (
<AlertCircle className="size-4 shrink-0 text-rose-400 dark:text-rose-600" />
) : (
<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={confirmAge} />}</AnimatePresence>
</div>
</MotionConfig>
</StoreContext.Provider>
);
}
function PageSpinner({ label = "Loading…" }: { label?: string }) {
return (
<div className="grid place-items-center px-6 py-28 text-center" role="status">
<span className="relative grid size-12 place-items-center">
<span className="absolute inset-0 animate-spin rounded-full border-2 border-foreground/15 border-t-foreground motion-reduce:animate-none" />
<Logo className="scale-75 [&>span:last-child]:hidden" />
</span>
<p className="mt-4 text-sm text-muted-foreground">{label}</p>
</div>
);
}
export default OnlineStoreKit;