Fazekit

Code

"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;

More in E-commerce

View all →