Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, MotionConfig, useReducedMotion } from "motion/react";
import { ArrowUp, Braces, History, RotateCcw, ShoppingBag, Square } from "lucide-react";
import { cn } from "@/lib/utils";
import { BlockFrame, BlockSkeleton, BlockView, ProductCard, type BlockCtx } from "./blocks";
import { CartDrawer } from "./cart-drawer";
import { BASE_DATE, CATEGORY_LABEL, HOME_CATEGORIES, POPULAR_IDS, PRODUCTS, SUGGESTIONS } from "./data";
import { createLocalGenerator, isAsyncIterable, looksLikeRefinement, makeFmt, serializeBlock, serializeConstraints, type Fmt } from "./engine";
import { ProductArt } from "./product-art";
import { SpecPanel, type SpecSegment } from "./spec-panel";
import type { BlockSpec, CartLine, Constraint, GenerateMode, GenerateRequest, GenerateResult, PageSpec, Product, SpecChunk } from "./types";
import { BrandMark, ROOT_VARS, SparkIcon, focusRing, useDialog } from "./ui";

export type { BlockSpec, Constraint, GenerateRequest, GenerateResult, PageSpec, Product, SpecChunk };

export type GenuiStorefrontAppProps = {
  /** Catalogue. Default: 32 seeded outdoor products. */
  products?: Product[];
  storeName?: string;
  currency?: string;
  locale?: string;
  suggestions?: string[];
  /** "Today" (YYYY-MM-DD) for delivery copy. */
  today?: string;
  /**
   * Replace the local engine with a model. Return a full page spec (Promise) or stream
   * `{ constraints }` then `{ block }` chunks. Blocks are plain JSON from the component set.
   */
  generate?: (req: GenerateRequest) => GenerateResult;
  onGenerate?: (spec: PageSpec) => void;
  onAddToCart?: (product: Product, qty: number) => void;
  onCheckout?: (lines: CartLine[]) => void;
  /** Show the spec panel on wide screens (default true). */
  showSpec?: boolean;
  freeShippingOver?: number;
  className?: string;
};

type Version = { spec: PageSpec; segments: SpecSegment[]; changed: string[]; label: string; partial: boolean };
type Building = { prompt: string; constraints: Constraint[]; blocks: BlockSpec[]; segments: SpecSegment[]; note: string };
type RunReq = { prompt: string; mode: GenerateMode; constraints?: Constraint[]; label?: string };

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

async function* chunksOf(spec: PageSpec): AsyncIterable<SpecChunk> {
  yield { constraints: spec.constraints, note: spec.note };
  for (const block of spec.blocks) yield { block };
}

function useMedia(query: string) {
  const [match, setMatch] = React.useState(false);
  React.useEffect(() => {
    const m = window.matchMedia(query);
    const on = () => setMatch(m.matches);
    on();
    m.addEventListener("change", on);
    return () => m.removeEventListener("change", on);
  }, [query]);
  return match;
}

export function GenuiStorefrontApp({
  products = PRODUCTS,
  storeName = "Northwind Supply",
  currency = "PLN",
  locale = "pl-PL",
  suggestions = SUGGESTIONS,
  today = BASE_DATE,
  generate,
  onGenerate,
  onAddToCart,
  onCheckout,
  showSpec = true,
  freeShippingOver = 200,
  className,
}: GenuiStorefrontAppProps) {
  const reduced = useReducedMotion() ?? false;
  const fmt: Fmt = React.useMemo(() => makeFmt(currency, locale), [currency, locale]);
  const byId = React.useMemo(() => new Map(products.map((p) => [p.id, p])), [products]);
  const local = React.useMemo(() => createLocalGenerator({ fmt, today }), [fmt, today]);
  const wide = useMedia("(min-width: 1024px)");
  const smUp = useMedia("(min-width: 640px)");

  const [versions, setVersions] = React.useState<Version[]>([]);
  const [active, setActive] = React.useState(-1);
  const [building, setBuilding] = React.useState<Building | null>(null);
  const [error, setError] = React.useState<{ message: string; req: RunReq } | null>(null);
  const [hovered, setHovered] = React.useState<string | null>(null);
  const [cart, setCart] = React.useState<CartLine[]>([]);
  const [cartOpen, setCartOpen] = React.useState(false);
  const [specPanel, setSpecPanel] = React.useState(showSpec);
  const [specSheet, setSpecSheet] = React.useState(false);
  const [gift, setGift] = React.useState({ wrap: false, message: "" });
  const [draft, setDraft] = React.useState("");
  const [announce, setAnnounce] = React.useState("");
  const [bump, setBump] = React.useState(0);

  const runRef = React.useRef(0);
  const abortRef = React.useRef<AbortController | null>(null);
  const mainRef = React.useRef<HTMLDivElement>(null);
  const inputTop = React.useRef<HTMLInputElement>(null);
  const inputBottom = React.useRef<HTMLInputElement>(null);
  const cartBtn = React.useRef<HTMLButtonElement>(null);
  const specBtn = React.useRef<HTMLButtonElement>(null);
  const sheetRef = React.useRef<HTMLDivElement>(null);
  const versionsRef = React.useRef(versions);
  const activeRef = React.useRef(active);
  React.useEffect(() => {
    versionsRef.current = versions;
    activeRef.current = active;
  });

  useDialog(specSheet, sheetRef, () => setSpecSheet(false), specBtn);

  const current = active >= 0 ? versions[active] : null;
  const latest = versions.length - 1;
  const readOnly = active >= 0 && active !== latest;

  /* ------------------------------- generating ------------------------------ */

  const stop = React.useCallback(() => abortRef.current?.abort(), []);

  const run = React.useCallback(
    async (req: RunReq) => {
      const prompt = req.prompt.trim();
      if (!prompt) return;
      abortRef.current?.abort();
      const ac = new AbortController();
      abortRef.current = ac;
      const runId = ++runRef.current;
      const alive = () => runRef.current === runId;
      const vs = versionsRef.current;
      const prev = vs.length ? vs[vs.length - 1] : null;
      const mode: GenerateMode = req.mode === "new" && prev && looksLikeRefinement(prompt) ? "refine" : req.mode;
      const baseConstraints = req.constraints ?? (mode === "new" ? [] : prev?.spec.constraints ?? []);
      const displayPrompt = mode === "new" ? prompt : prev?.spec.prompt ?? prompt;

      let b: Building = { prompt: displayPrompt, constraints: [], blocks: [], segments: [], note: "Understanding your request…" };
      const push = (next: Building) => {
        b = next;
        if (alive()) setBuilding(next);
      };
      push(b);
      setError(null);
      setDraft("");
      setActive(-2);
      setAnnounce("Building a page for your request");
      mainRef.current?.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" });

      const typeSegment = async (id: string, text: string) => {
        const seg: SpecSegment = { id, text: "", done: false };
        push({ ...b, segments: [...b.segments, seg] });
        if (!reduced) {
          const step = 7;
          for (let i = step; i < text.length; i += step) {
            await sleep(16);
            if (!alive()) return;
            push({ ...b, segments: b.segments.map((s) => (s.id === id ? { ...s, text: text.slice(0, i) } : s)) });
          }
        }
        push({ ...b, segments: b.segments.map((s) => (s.id === id ? { ...s, text, done: true } : s)) });
      };

      const commit = (partial: boolean) => {
        if (!b.blocks.length) return false;
        const spec: PageSpec = { prompt: b.prompt, constraints: b.constraints, blocks: b.blocks, note: b.note };
        const changed =
          mode === "new" || !prev
            ? []
            : spec.blocks
                .filter((blk) => {
                  const old = prev.spec.blocks.find((o) => o.id === blk.id);
                  return !old || JSON.stringify(old) !== JSON.stringify(blk);
                })
                .map((blk) => blk.id);
        const label = req.label ?? prompt.charAt(0).toUpperCase() + prompt.slice(1);
        const version: Version = { spec, segments: b.segments.map((s) => ({ ...s, done: true })), changed, label, partial };
        const idx = versionsRef.current.length;
        versionsRef.current = [...versionsRef.current, version];
        setVersions(versionsRef.current);
        setActive(idx);
        setBuilding(null);
        onGenerate?.(spec);
        const hero = spec.blocks.find((x) => x.type === "intent-hero");
        setAnnounce(`Generated ${spec.blocks.length} blocks${hero && hero.type === "intent-hero" ? ` for ${hero.title}` : ""}${partial ? " (stopped early)" : ""}`);
        return true;
      };

      try {
        const request: GenerateRequest = { prompt, mode, constraints: baseConstraints, previous: prev?.spec ?? null, products, signal: ac.signal };
        const result = (generate ?? local)(request);
        const stream: AsyncIterable<SpecChunk> = isAsyncIterable<SpecChunk>(result) ? result : chunksOf(await result);
        for await (const ch of stream) {
          if (!alive()) return;
          if ("block" in ch) {
            await typeSegment(ch.block.id, serializeBlock(ch.block, products));
            if (!alive()) return;
            push({ ...b, blocks: [...b.blocks, ch.block] });
          } else if ("constraints" in ch) {
            push({ ...b, constraints: ch.constraints, note: ch.note ?? b.note });
            await typeSegment("constraints", serializeConstraints(ch.constraints, displayPrompt));
          } else {
            push({ ...b, note: ch.note });
          }
        }
        if (!alive()) return;
        if (!commit(false)) throw new Error("The generator returned no blocks.");
      } catch (e) {
        if (!alive()) return;
        const aborted = e instanceof DOMException && e.name === "AbortError";
        if (aborted) {
          if (!commit(true)) {
            setBuilding(null);
            setActive(versionsRef.current.length - 1);
            setAnnounce("Stopped");
          }
          return;
        }
        setBuilding(null);
        setActive(versionsRef.current.length - 1);
        setError({ message: e instanceof Error ? e.message : "Something went wrong.", req });
      }
    },
    [generate, local, products, reduced, onGenerate],
  );

  React.useEffect(() => () => abortRef.current?.abort(), []);

  const reset = () => {
    abortRef.current?.abort();
    runRef.current++;
    setBuilding(null);
    setActive(-1);
    setError(null);
    mainRef.current?.scrollTo({ top: 0 });
    setAnnounce("Back to the shop homepage");
  };

  /* --------------------------------- cart --------------------------------- */

  const qtyOf = React.useCallback((id: string) => cart.find((l) => l.productId === id)?.qty ?? 0, [cart]);
  const add = React.useCallback(
    (id: string, qty = 1) => {
      setCart((c) => (c.some((l) => l.productId === id) ? c.map((l) => (l.productId === id ? { ...l, qty: l.qty + qty } : l)) : [...c, { productId: id, qty }]));
      setBump((n) => n + 1);
      const p = byId.get(id);
      if (p) {
        onAddToCart?.(p, qty);
        setAnnounce(`Added ${p.name} to cart`);
      }
    },
    [byId, onAddToCart],
  );
  const setQty = (id: string, qty: number) => setCart((c) => (qty <= 0 ? c.filter((l) => l.productId !== id) : c.map((l) => (l.productId === id ? { ...l, qty } : l))));
  const count = cart.reduce((t, l) => t + l.qty, 0);

  /* ------------------------------- shortcuts ------------------------------ */

  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement | null;
      const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
      if (e.key === "/" && !typing) {
        e.preventDefault();
        const el = [inputTop.current, inputBottom.current].find((i) => i && i.offsetParent !== null);
        el?.focus();
      }
      if (e.key === "Escape" && abortRef.current && !abortRef.current.signal.aborted && runRef.current && document.querySelector("[data-gs-streaming='true']")) stop();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [stop]);

  /* ------------------------------ derived view ----------------------------- */

  const shownBlocks = building ? building.blocks : current?.spec.blocks ?? [];
  const shownConstraints = building ? building.constraints : current?.spec.constraints ?? [];
  const segments = building ? building.segments : current?.segments ?? [];
  const note = building ? building.note : current?.spec.note ?? "";
  const changed = building ? [] : current?.changed ?? [];
  const streaming = !!building;
  const hoverBlock = hovered === "constraints" ? "intent-hero" : hovered;

  const ctx: BlockCtx = {
    products: byId,
    fmt,
    qtyOf,
    add,
    constraints: shownConstraints,
    readOnly: readOnly || streaming,
    onRemoveConstraint: (c) => void run({ prompt: `Removed “${c.label}”`, mode: "edit", constraints: shownConstraints.filter((x) => x.id !== c.id) }),
    onBudget: (v) =>
      void run({ prompt: `Budget ${fmt.money(v)}`, mode: "edit", constraints: shownConstraints.map((c) => (c.kind === "budget" ? { ...c, value: v, label: `Under ${fmt.money(v)}` } : c)) }),
    onRefine: (p) => void run({ prompt: p, mode: "refine" }),
    onRelax: (label, constraints) => void run({ prompt: label, mode: "edit", constraints }),
    onReset: reset,
    gift,
    setGift,
  };

  const selectBlock = (id: string) => {
    const blockId = id === "constraints" ? "intent-hero" : id;
    const el = mainRef.current?.querySelector<HTMLElement>(`#gs-block-${CSS.escape(blockId)}`);
    el?.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" });
    setSpecSheet(false);
  };

  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (streaming) return;
    void run({ prompt: draft, mode: "new" });
  };

  const intentForm = (where: "top" | "bottom") => (
    <form onSubmit={onSubmit} className={cn("relative flex min-w-0 flex-1 items-center gap-2 rounded-2xl border bg-card p-1 pl-3 shadow-sm transition-shadow focus-within:ring-2 focus-within:ring-ring", where === "top" && "max-w-2xl")}>
      <SparkIcon className="size-4 shrink-0 text-[var(--gs-accent)]" />
      <label htmlFor={`gs-intent-${where}`} className="sr-only">
        Tell the shop what you need
      </label>
      <input
        ref={where === "top" ? inputTop : inputBottom}
        id={`gs-intent-${where}`}
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        placeholder={versions.length ? "Refine: “cheaper”, “only jackets”… or ask anew" : "Tell the shop what you need…"}
        autoComplete="off"
        className="h-9 min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-muted-foreground"
      />
      <kbd className="hidden rounded border bg-muted px-1.5 font-mono text-[10.5px] text-muted-foreground md:block" aria-hidden>
        /
      </kbd>
      {streaming ? (
        <button type="button" onClick={stop} aria-label="Stop generating" className={cn("grid size-9 shrink-0 place-items-center rounded-xl bg-foreground text-background", focusRing)}>
          <Square className="size-3.5 fill-current" aria-hidden />
        </button>
      ) : (
        <button type="submit" disabled={!draft.trim()} aria-label="Build page" className={cn("grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--gs-accent)] text-white transition-opacity disabled:opacity-40", focusRing)}>
          <ArrowUp className="size-4" aria-hidden />
        </button>
      )}
    </form>
  );

  const specProps = { segments, streaming, note, hovered, onHover: setHovered, onSelect: selectBlock };

  return (
    <MotionConfig reducedMotion="user">
      <div className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", ROOT_VARS, className)}>
        <div className="flex min-h-0 flex-1 flex-col" inert={cartOpen || specSheet ? true : undefined}>
          {/* Header */}
          <header className="flex h-14 shrink-0 items-center gap-3 border-b px-3 sm:px-4">
            <button type="button" onClick={reset} className={cn("flex shrink-0 items-center gap-2 rounded-xl", focusRing)} aria-label={`${storeName} home`}>
              <BrandMark />
              <span className="text-[14px] font-semibold tracking-tight sm:hidden md:inline">{storeName}</span>
            </button>
            <div className="hidden min-w-0 flex-1 justify-center sm:flex">{intentForm("top")}</div>
            <div className="ml-auto flex items-center gap-1 sm:ml-0">
              <button
                ref={specBtn}
                type="button"
                onClick={() => (wide ? setSpecPanel((s) => !s) : setSpecSheet(true))}
                aria-pressed={wide ? specPanel : undefined}
                aria-label={wide ? (specPanel ? "Hide page spec" : "Show page spec") : "Open page spec"}
                className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl px-2.5 text-[12.5px] font-medium hover:bg-accent", wide && specPanel && "bg-accent", focusRing)}
              >
                <Braces className="size-4" aria-hidden />
                <span className="hidden md:inline">Spec</span>
                {streaming && <span className="size-1.5 animate-pulse rounded-full bg-[var(--gs-accent)]" aria-hidden />}
              </button>
              <button ref={cartBtn} type="button" onClick={() => setCartOpen(true)} aria-label={`Cart, ${count} items`} className={cn("relative grid size-9 place-items-center rounded-xl hover:bg-accent", focusRing)}>
                <ShoppingBag className="size-4.5" aria-hidden />
                <AnimatePresence>
                  {count > 0 && (
                    <motion.span
                      key={bump}
                      initial={reduced ? false : { scale: 1.8 }}
                      animate={{ scale: 1 }}
                      transition={{ type: "spring", stiffness: 500, damping: 14 }}
                      className="absolute -right-0.5 -top-0.5 grid h-4.5 min-w-4.5 place-items-center rounded-full bg-[var(--gs-accent)] px-1 text-[10.5px] font-semibold text-white tabular-nums"
                    >
                      {count}
                    </motion.span>
                  )}
                </AnimatePresence>
              </button>
            </div>
          </header>

          {/* Versions */}
          <AnimatePresence initial={false}>
            {(versions.length > 0 || streaming) && (
              <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="shrink-0 overflow-hidden border-b bg-muted/30">
                <div className="flex h-11 items-center gap-2 px-3 sm:px-4">
                  <History className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
                  <span className="hidden text-[12px] font-medium text-muted-foreground sm:inline">Page versions</span>
                  <div role="tablist" aria-label="Page versions" className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto py-1 [scrollbar-width:none]">
                    {versions.map((v, i) => (
                      <button
                        key={i}
                        type="button"
                        role="tab"
                        aria-selected={active === i}
                        title={v.label}
                        disabled={streaming}
                        onClick={() => setActive(i)}
                        className={cn(
                          "relative inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium transition-colors disabled:opacity-50",
                          active === i ? "text-foreground" : "text-muted-foreground hover:text-foreground",
                          focusRing,
                        )}
                      >
                        {active === i && <motion.span layoutId="gs-version-pill" className="absolute inset-0 rounded-lg border bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
                        <span className="relative tabular-nums">v{i + 1}</span>
                        <span className="relative hidden max-w-36 truncate font-normal text-muted-foreground md:inline">{v.label}</span>
                      </button>
                    ))}
                    {streaming && (
                      <span className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-dashed border-[var(--gs-accent)]/50 px-2.5 text-[12px] font-medium text-[var(--gs-accent)]">
                        <motion.span className="size-1.5 rounded-full bg-[var(--gs-accent)]" animate={reduced ? undefined : { opacity: [1, 0.25, 1] }} transition={{ duration: 0.9, repeat: Infinity }} />v{versions.length + 1}
                      </span>
                    )}
                  </div>
                  {streaming && (
                    <button type="button" onClick={stop} className={cn("inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg border bg-background px-2.5 text-[12px] font-medium", focusRing)}>
                      <Square className="size-3 fill-current" aria-hidden /> Stop <kbd className="hidden font-mono text-[10px] text-muted-foreground sm:inline">esc</kbd>
                    </button>
                  )}
                </div>
              </motion.div>
            )}
          </AnimatePresence>

          <div className="flex min-h-0 flex-1">
            {/* Page */}
            <div ref={mainRef} className="@container min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden" role="region" aria-label="Store page" aria-busy={streaming} data-gs-streaming={streaming}>
              <div className="mx-auto max-w-5xl space-y-5 px-3 py-4 sm:px-6 sm:py-6">
                {readOnly && (
                  <div className="flex items-center gap-3 rounded-2xl border bg-muted/40 px-4 py-2.5 text-[12.5px]">
                    <History className="size-4 text-muted-foreground" aria-hidden />
                    <span className="min-w-0 flex-1">
                      Viewing snapshot <strong>v{active + 1}</strong>. Edits apply to the latest version.
                    </span>
                    <button type="button" onClick={() => setActive(latest)} className={cn("shrink-0 rounded-lg bg-foreground px-2.5 py-1 text-[12px] font-medium text-background", focusRing)}>
                      Back to v{latest + 1}
                    </button>
                  </div>
                )}
                {current?.partial && !streaming && (
                  <p className="rounded-2xl border border-dashed px-4 py-2 text-[12.5px] text-muted-foreground">Generation was stopped early. Refine or ask again to finish the page.</p>
                )}

                {error && (
                  <div role="alert" className="flex flex-wrap items-center gap-3 rounded-2xl border border-destructive/40 bg-destructive/5 px-4 py-3 text-[13px]">
                    <span className="min-w-0 flex-1">
                      <strong>Couldn&apos;t build the page.</strong> {error.message}
                    </span>
                    <button type="button" onClick={() => void run(error.req)} className={cn("inline-flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-[12.5px] font-medium text-background", focusRing)}>
                      <RotateCcw className="size-3.5" aria-hidden /> Retry
                    </button>
                  </div>
                )}

                {active === -1 && !streaming ? (
                  <Home storeName={storeName} suggestions={suggestions} products={byId} fmt={fmt} ctx={ctx} onAsk={(p) => void run({ prompt: p, mode: "new" })} />
                ) : (
                  <LayoutGroup>
                    <AnimatePresence mode="popLayout">
                      {shownBlocks.map((blk) => (
                        <BlockFrame key={blk.id} block={blk} highlighted={hoverBlock === blk.id} changed={changed.includes(blk.id)}>
                          <BlockView block={blk} ctx={ctx} />
                        </BlockFrame>
                      ))}
                    </AnimatePresence>
                    {streaming && <BlockSkeleton />}
                  </LayoutGroup>
                )}
              </div>
            </div>

            {/* Spec panel (wide) */}
            <AnimatePresence initial={false}>
              {wide && specPanel && (
                <motion.aside
                  key="spec"
                  aria-label="Page spec"
                  initial={{ width: 0, opacity: 0 }}
                  animate={{ width: 340, opacity: 1 }}
                  exit={{ width: 0, opacity: 0 }}
                  transition={{ type: "spring", stiffness: 300, damping: 34 }}
                  className="shrink-0 overflow-hidden border-l bg-muted/20"
                >
                  <div className="h-full w-[340px]">
                    <SpecPanel {...specProps} />
                  </div>
                </motion.aside>
              )}
            </AnimatePresence>
          </div>

          {/* Bottom intent bar (phones) */}
          <div className="shrink-0 border-t bg-background/90 p-2.5 pb-[max(0.625rem,env(safe-area-inset-bottom))] backdrop-blur sm:hidden">
            {!versions.length && !streaming && (
              <div className="-mx-2.5 mb-2 flex gap-1.5 overflow-x-auto px-2.5 [scrollbar-width:none]">
                {suggestions.map((s) => (
                  <button key={s} type="button" onClick={() => void run({ prompt: s, mode: "new" })} className={cn("h-8 shrink-0 rounded-full border bg-card px-3 text-[12px]", focusRing)}>
                    {s}
                  </button>
                ))}
              </div>
            )}
            {intentForm("bottom")}
          </div>
        </div>

        {/* Spec sheet (narrow) */}
        <AnimatePresence>
          {specSheet && (
            <>
              <motion.div key="scrim" className="absolute inset-0 z-40 bg-black/40" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setSpecSheet(false)} aria-hidden />
              <motion.div
                key="sheet"
                ref={sheetRef}
                role="dialog"
                aria-modal="true"
                aria-label="Page spec"
                className="absolute inset-x-0 bottom-0 z-50 h-[70%] overflow-hidden rounded-t-3xl border-t bg-background shadow-2xl"
                initial={reduced ? { opacity: 0 } : { y: "100%" }}
                animate={reduced ? { opacity: 1 } : { y: 0 }}
                exit={reduced ? { opacity: 0 } : { y: "100%" }}
                transition={{ type: "spring", stiffness: 380, damping: 38 }}
              >
                <SpecPanel {...specProps} onClose={() => setSpecSheet(false)} />
              </motion.div>
            </>
          )}
        </AnimatePresence>

        <CartDrawer
          open={cartOpen}
          onClose={() => setCartOpen(false)}
          lines={cart}
          products={byId}
          fmt={fmt}
          wrap={gift.wrap}
          wrapPrice={15}
          freeShippingOver={freeShippingOver}
          setQty={setQty}
          onCheckout={() => {
            onCheckout?.(cart);
            setCart([]);
          }}
          returnTo={cartBtn}
          side={smUp ? "right" : "bottom"}
        />

        <p className="sr-only" aria-live="polite">
          {announce}
        </p>
      </div>
    </MotionConfig>
  );
}

/* -------------------------------------------------------------------------- */
/*                                   Home                                      */
/* -------------------------------------------------------------------------- */

function Home({
  storeName,
  suggestions,
  products,
  fmt,
  ctx,
  onAsk,
}: {
  storeName: string;
  suggestions: string[];
  products: Map<string, Product>;
  fmt: Fmt;
  ctx: BlockCtx;
  onAsk: (prompt: string) => void;
}) {
  const reduced = useReducedMotion() ?? false;
  const popular = POPULAR_IDS.map((id) => products.get(id)).filter((p): p is Product => !!p);
  const fallback = popular.length ? popular : [...products.values()].slice(0, 4);
  const tiles = HOME_CATEGORIES.map((c) => ({ c, p: [...products.values()].find((p) => p.category === c) })).filter((t): t is { c: (typeof HOME_CATEGORIES)[number]; p: Product } => !!t.p);
  return (
    <motion.div initial={reduced ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="space-y-6">
      <section className="relative overflow-hidden rounded-3xl border bg-gradient-to-br from-[var(--gs-soft)] via-card to-card">
        <svg viewBox="0 0 600 260" preserveAspectRatio="xMaxYMax slice" className="absolute inset-0 h-full w-full" aria-hidden>
          <circle cx="470" cy="80" r="34" fill="var(--gs-accent)" opacity="0.55" />
          <path d="M180 260 L330 90 L410 170 L470 120 L600 230 V260 Z" fill="var(--gs-accent-2)" opacity="0.16" />
          <path d="M260 260 L400 130 L470 190 L520 150 L600 210 V260 Z" fill="var(--gs-accent-2)" opacity="0.24" />
          <path d="M330 260 L450 170 L520 220 L600 180 V260 Z" fill="var(--gs-accent)" opacity="0.22" />
          <path d="M330 90 L350 112 L338 110 L330 120 L320 110 L310 112 Z" fill="#fff" opacity="0.7" />
        </svg>
        <div className="relative max-w-xl p-6 sm:p-9">
          <p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[var(--gs-accent)]">{storeName} · Autumn edit</p>
          <h1 className="mt-2 text-balance text-[28px] font-semibold leading-[1.1] tracking-tight sm:text-[40px]">Gear for the long way round</h1>
          <p className="mt-3 max-w-md text-[14px] text-muted-foreground">Tell the shop what you need. This page rebuilds itself around your request: picks, a comparison, a bundle that fits your budget.</p>
          <ul className="mt-5 hidden flex-wrap gap-2 sm:flex" aria-label="Try asking">
            {suggestions.map((s, i) => (
              <motion.li key={s} initial={reduced ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: reduced ? 0 : 0.15 + i * 0.06 }}>
                <button
                  type="button"
                  onClick={() => onAsk(s)}
                  className={cn("inline-flex h-9 items-center gap-1.5 rounded-full border bg-background/80 px-3.5 text-[12.5px] font-medium shadow-sm backdrop-blur transition hover:-translate-y-px hover:border-[var(--gs-accent)]/50", focusRing)}
                >
                  <SparkIcon className="text-[var(--gs-accent)]" />
                  {s}
                </button>
              </motion.li>
            ))}
          </ul>
        </div>
      </section>

      <section aria-label="Shop by category">
        <h2 className="mb-3 px-1 text-[15px] font-semibold tracking-tight">Shop by category</h2>
        <ul className="grid grid-cols-3 gap-2.5 @3xl:grid-cols-6">
          {tiles.map(({ c, p }) => (
            <li key={c}>
              <button type="button" onClick={() => onAsk(`Show me ${CATEGORY_LABEL[c].many.toLowerCase()}`)} className={cn("group w-full rounded-2xl border bg-card p-2 text-left transition hover:-translate-y-0.5 hover:shadow-md", focusRing)}>
                <ProductArt product={p} className="aspect-square w-full" />
                <span className="mt-1.5 block truncate px-0.5 text-[12.5px] font-medium">{CATEGORY_LABEL[c].many}</span>
              </button>
            </li>
          ))}
        </ul>
      </section>

      <section aria-label="Popular right now">
        <h2 className="mb-3 px-1 text-[15px] font-semibold tracking-tight">Popular right now</h2>
        <div className="grid grid-cols-2 gap-3 @3xl:grid-cols-4">
          {fallback.map((p) => (
            <ProductCard key={p.id} product={p} ctx={{ ...ctx, fmt }} />
          ))}
        </div>
      </section>
    </motion.div>
  );
}

More in E-commerce

View all →