Fazekit

Code

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

More in E-commerce

View all →