"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'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>
);
}