"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring } from "motion/react";
import { Box, ChevronDown, ChevronUp, CornerUpLeft, CornerUpRight, List, Map as MapIcon, RotateCcw, RotateCw, ShoppingBasket, Trash2, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { AisleScene, LOOK, geometry } from "./aisle-scene";
import { LAYOUT, PRODUCTS, SUGGESTED_QUERIES, SYNONYMS } from "./data";
import { Directory } from "./directory";
import { createLocalFinder, makeMoney, nearestCategories } from "./engine";
import { FlatView } from "./flat-view";
import { Minimap } from "./minimap";
import { ProductCard } from "./product-card";
import type { AisleDef, BasketLine, FindFn, Product, Side, Synonyms } from "./types";
import { ROOT_VARS, focusRing, useDialog, useMedia, useSize } from "./ui";
import { Wayfinder, type WayState, type WayfinderHandle } from "./wayfinder";
export type { AisleDef, BasketLine, FindFn, Product, Synonyms };
export type WalkableAisleAppProps = {
/** Aisles with the category on each side of each bay. Default: 4 aisles × 8 bays. */
layout?: AisleDef[];
/** Products placed by aisle / bay / side / shelf. Default: 256 seeded SKUs. */
products?: Product[];
/** Extra search words per category label for the local wayfinder. */
synonyms?: Synonyms;
/** Replace the local wayfinder (e.g. with embeddings or a model). Best match first. */
find?: FindFn;
onAdd?: (product: Product, qty: number) => void;
onCheckout?: (lines: BasketLine[]) => void;
storeName?: string;
currency?: string;
locale?: string;
suggestions?: string[];
initialAisle?: number;
/** Force a view. Default: 3D, or 2D when the user prefers reduced motion. */
initialView?: "3d" | "2d";
className?: string;
};
type Card = { product: Product; from: DOMRect | null };
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
const noop = () => {};
export function WalkableAisleApp({
layout = LAYOUT,
products = PRODUCTS,
synonyms = SYNONYMS,
find,
onAdd,
onCheckout,
storeName = "Lumen Market",
currency = "PLN",
locale = "pl-PL",
suggestions = SUGGESTED_QUERIES,
initialAisle = 0,
initialView,
className,
}: WalkableAisleAppProps) {
const reduced = useReducedMotion() ?? false;
const mobile = !useMedia("(min-width: 768px)");
const money = React.useMemo(() => makeMoney(currency, locale), [currency, locale]);
const localFind = React.useMemo(() => createLocalFinder(synonyms), [synonyms]);
const rootRef = React.useRef<HTMLDivElement>(null);
const sceneRef = React.useRef<HTMLDivElement>(null);
const askRef = React.useRef<WayfinderHandle>(null);
const size = useSize(sceneRef);
const geo = React.useMemo(() => (size ? geometry(size.w, size.h) : null), [size]);
const [aisle, setAisle] = React.useState(clamp(initialAisle, 0, layout.length - 1));
const [viewPref, setViewPref] = React.useState<"3d" | "2d" | null>(initialView ?? null);
const view = viewPref ?? (reduced ? "2d" : "3d");
const def = layout[aisle];
const bays = def.bays.length;
// Camera: targets are set by input; springs smooth them; the scene reads the springs.
const posT = useMotionValue(0);
const yawT = useMotionValue(0);
const springCfg = reduced || view === "2d" ? { stiffness: 1000, damping: 100 } : { stiffness: 64, damping: 17, mass: 1 };
const pos = useSpring(posT, springCfg);
const yaw = useSpring(yawT, reduced ? { stiffness: 1000, damping: 100 } : { stiffness: 80, damping: 18 });
const [bay, setBay] = React.useState(0);
const [facing, setFacing] = React.useState<Side | null>(null);
const [shelf, setShelf] = React.useState(1);
const [card, setCard] = React.useState<Card | null>(null);
const [way, setWay] = React.useState<WayState>({ status: "idle" });
const [target, setTarget] = React.useState<{ aisle: number; bay: number; side: Side } | null>(null);
const [basket, setBasket] = React.useState<BasketLine[]>([]);
const [basketOpen, setBasketOpen] = React.useState(false);
const [dirOpen, setDirOpen] = React.useState(false);
const [mapOpen, setMapOpen] = React.useState(false);
const [intro, setIntro] = React.useState(true);
const [fade, setFade] = React.useState(false);
const [announce, setAnnounce] = React.useState("");
const basketBtn = React.useRef<HTMLButtonElement>(null);
useMotionValueEvent(posT, "change", (v) => {
const r = clamp(Math.round(v), 0, bays - 1);
setBay((b) => (b === r ? b : r));
});
useMotionValueEvent(yawT, "change", (v) => {
const f: Side | null = v <= -LOOK / 2 ? "left" : v >= LOOK / 2 ? "right" : null;
setFacing((x) => (x === f ? x : f));
});
React.useEffect(() => {
const t = setTimeout(() => {
const b = def.bays[bay];
setAnnounce(`Aisle ${aisle + 1}, ${def.name}, bay ${bay + 1}: ${b.left} on the left, ${b.right} on the right${facing ? `. Facing ${facing}: ${facing === "left" ? b.left : b.right}` : ""}.`);
}, 350);
return () => clearTimeout(t);
}, [aisle, bay, facing, def]);
React.useEffect(() => {
const t = setTimeout(() => setIntro(false), 9000);
return () => clearTimeout(t);
}, []);
const productAt = React.useCallback((ai: number, bi: number, side: Side, sh: number) => products.find((p) => p.aisle === ai && p.bay === bi && p.side === side && p.shelf === sh), [products]);
const focused = facing ? (productAt(aisle, bay, facing, shelf) ?? null) : null;
/* ------------------------------ movement ------------------------------ */
const live = React.useRef({ aisle, bays, layoutLen: layout.length, view });
React.useEffect(() => {
live.current = { aisle, bays, layoutLen: layout.length, view };
});
const switchAisle = React.useCallback(
(next: number, toBay: number, then?: () => void) => {
if (next < 0 || next >= live.current.layoutLen) return;
const dir = next > live.current.aisle ? 1 : -1;
if (!reduced && live.current.view === "3d") {
yawT.set(dir * 80);
setFade(true);
}
setTimeout(
() => {
setAisle(next);
posT.jump(toBay);
pos.jump(toBay);
yawT.jump(0);
yaw.jump(0);
setBay(toBay);
setFade(false);
then?.();
},
reduced || live.current.view === "2d" ? 0 : 320,
);
},
[pos, posT, reduced, yaw, yawT],
);
const step = React.useCallback(
(dir: 1 | -1) => {
setIntro(false);
const L = live.current;
const cur = Math.round(posT.get());
if (dir === 1 && cur >= L.bays - 1) {
if (L.aisle < L.layoutLen - 1) switchAisle(L.aisle + 1, 0);
return;
}
if (dir === -1 && cur <= 0) {
if (L.aisle > 0) switchAisle(L.aisle - 1, layout[L.aisle - 1].bays.length - 1);
return;
}
posT.set(clamp(cur + dir, 0, L.bays - 1));
},
[layout, posT, switchAisle],
);
// Hold-to-walk: a rAF loop moves the target while a key or button is held.
const hold = React.useRef<{ dir: 1 | -1; raf: number; last: number; started: number } | null>(null);
const startHold = React.useCallback(
(dir: 1 | -1) => {
if (hold.current) return;
step(dir);
const h = { dir, raf: 0, last: performance.now(), started: performance.now() };
hold.current = h;
const tick = (t: number) => {
if (hold.current !== h) return;
const dt = Math.min(0.1, (t - h.last) / 1000);
h.last = t;
if (t - h.started > 260) posT.set(clamp(posT.get() + h.dir * dt * 2.6, 0, live.current.bays - 1));
h.raf = requestAnimationFrame(tick);
};
h.raf = requestAnimationFrame(tick);
},
[posT, step],
);
const stopHold = React.useCallback(() => {
const h = hold.current;
if (!h) return;
cancelAnimationFrame(h.raf);
hold.current = null;
posT.set(clamp(Math.round(posT.get()), 0, live.current.bays - 1));
}, [posT]);
const look = React.useCallback(
(dir: 1 | -1) => {
setIntro(false);
const cur = yawT.get();
const snapped = cur <= -LOOK / 2 ? -LOOK : cur >= LOOK / 2 ? LOOK : 0;
yawT.set(clamp(snapped + dir * LOOK, -LOOK, LOOK));
},
[yawT],
);
const teleport = React.useCallback(
(ai: number, bi: number, side?: Side) => {
setIntro(false);
const face = () => {
posT.set(bi);
yawT.set(side ? (side === "left" ? -LOOK : LOOK) : 0);
};
if (ai !== live.current.aisle) switchAisle(ai, Math.min(bi, 1), face);
else face();
},
[posT, switchAisle, yawT],
);
/* ------------------------------- picking ------------------------------ */
const openCard = React.useCallback((p: Product, el?: HTMLElement | null) => {
const src = el ?? rootRef.current?.querySelector<HTMLElement>(`[data-product="${p.id}"]`) ?? null;
setCard({ product: p, from: src ? src.getBoundingClientRect() : null });
setWay((w) => (w.status === "found" ? { status: "idle" } : w));
setShelf(p.shelf);
setAnnounce(`${p.name}, ${p.size}. Aisle ${p.aisle + 1}, bay ${p.bay + 1} ${p.side}, shelf ${p.shelf + 1}.`);
}, []);
/** Walk to a product, face its bay, then lift it into the card. */
const goTo = React.useCallback(
(p: Product) => {
setTarget({ aisle: p.aisle, bay: p.bay, side: p.side });
setShelf(p.shelf);
setCard(null);
teleport(p.aisle, p.bay, p.side);
const started = performance.now();
const wait = () => {
const settled = live.current.aisle === p.aisle && Math.abs(pos.get() - p.bay) < 0.04 && Math.abs(yaw.get() - (p.side === "left" ? -LOOK : LOOK)) < 1.5;
if (settled || performance.now() - started > 4000) {
requestAnimationFrame(() => openCard(p));
return;
}
requestAnimationFrame(wait);
};
requestAnimationFrame(wait);
},
[openCard, pos, teleport, yaw],
);
const askSeq = React.useRef(0);
const ask = React.useCallback(
async (q: string) => {
const id = ++askSeq.current;
setWay({ status: "searching", query: q });
try {
const res = await Promise.resolve((find ?? localFind)(q, products));
if (id !== askSeq.current) return;
if (!res.length) {
const labels = [...new Set(products.map((p) => p.category))];
setWay({ status: "none", query: q, suggestions: nearestCategories(q, labels) });
setTarget(null);
setAnnounce(`No match for ${q}.`);
return;
}
setWay({ status: "found", query: q, products: res });
const best = res[0];
setAnnounce(`${best.name}: aisle ${best.aisle + 1}, bay ${best.bay + 1} ${best.side}. Walking there.`);
goTo(best);
} catch (e) {
if (id !== askSeq.current) return;
setWay({ status: "error", query: q, message: e instanceof Error ? e.message : "Search failed." });
}
},
[find, goTo, localFind, products],
);
const add = (p: Product, qty: number) => {
setBasket((b) => {
const i = b.findIndex((l) => l.product.id === p.id);
if (i < 0) return [...b, { product: p, qty }];
return b.map((l, j) => (j === i ? { ...l, qty: l.qty + qty } : l));
});
setAnnounce(`Added ${qty} × ${p.name} to basket.`);
onAdd?.(p, qty);
};
const count = basket.reduce((s, l) => s + l.qty, 0);
const total = basket.reduce((s, l) => s + l.qty * l.product.price, 0);
/* ------------------------------ keyboard ------------------------------ */
const keyState = React.useRef({ facing, focused, card, dirOpen, basketOpen, view, bay });
React.useEffect(() => {
keyState.current = { facing, focused, card, dirOpen, basketOpen, view, bay };
});
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
const K = keyState.current;
if (e.key === "Escape" && (K.card || K.dirOpen || K.basketOpen) && !t?.closest("[role='dialog']")) {
setCard(null);
setDirOpen(false);
setBasketOpen(false);
return;
}
if (K.card || K.dirOpen || K.basketOpen || e.metaKey || e.ctrlKey || e.altKey) return;
if (t?.closest("input, textarea, select, [role='dialog']")) return;
const onButton = !!t?.closest("button, a, [role='button']");
const k = e.key;
if (k === "/") {
e.preventDefault();
askRef.current?.focus();
return;
}
if (k === "ArrowUp" || k === "ArrowDown" || k === "ArrowLeft" || k === "ArrowRight") {
e.preventDefault();
if (K.facing && (k === "ArrowUp" || k === "ArrowDown")) {
setShelf((s) => clamp(s + (k === "ArrowUp" ? -1 : 1), 0, 3));
return;
}
if (e.repeat) return;
startHold(k === "ArrowUp" || k === "ArrowRight" ? 1 : -1);
return;
}
if (k === "q" || k === "Q") look(-1);
else if (k === "e" || k === "E") look(1);
else if (k === "Enter" && !onButton) {
if (K.focused) {
e.preventDefault();
openCard(K.focused);
} else setAnnounce("Turn toward a shelf with Q or E, then press Enter to pick a product.");
}
};
const up = (e: KeyboardEvent) => {
if (e.key.startsWith("Arrow")) stopHold();
};
const blur = () => stopHold();
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("blur", blur);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("blur", blur);
};
}, [look, openCard, startHold, stopHold]);
/* -------------------------------- drag -------------------------------- */
const drag = React.useRef<{ x: number; y: number; pos: number; yaw: number; id: number; moving: boolean } | null>(null);
const onPointerDown = (e: React.PointerEvent) => {
if (view !== "3d" || e.button !== 0) return;
drag.current = { x: e.clientX, y: e.clientY, pos: posT.get(), yaw: yawT.get(), id: e.pointerId, moving: false };
};
const onPointerMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d || d.id !== e.pointerId || !geo) return;
const dx = e.clientX - d.x;
const dy = e.clientY - d.y;
if (!d.moving) {
if (Math.hypot(dx, dy) < 8) return;
d.moving = true;
setIntro(false);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
posT.set(clamp(d.pos - dy / (geo.h * 0.3), 0, bays - 1));
yawT.set(clamp(d.yaw - dx * 0.16, -LOOK - 12, LOOK + 12));
};
const onPointerUp = (e: React.PointerEvent) => {
const d = drag.current;
drag.current = null;
if (d && !d.moving && view === "3d" && e.type === "pointerup") {
// Chrome's hit testing inside preserve-3d planes can stop at the plane, so resolve the
// product under the pointer from the full hit stack instead of relying on the button's click.
const hit = document.elementsFromPoint(e.clientX, e.clientY).find((el) => el instanceof HTMLElement && el.dataset.product) as HTMLElement | undefined;
const p = hit ? products.find((x) => x.id === hit.dataset.product) : undefined;
if (p) {
setTarget(null);
setShelf(p.shelf);
openCard(p, hit);
}
return;
}
if (!d || !d.moving) return;
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
posT.set(clamp(Math.round(posT.get()), 0, bays - 1));
const y = yawT.get();
yawT.set(y <= -LOOK / 2 ? -LOOK : y >= LOOK / 2 ? LOOK : 0);
};
/* ------------------------------- render ------------------------------- */
const b = def.bays[bay];
const atEnd = bay === bays - 1 && !facing && aisle < layout.length - 1;
const atStart = bay === 0 && !facing && aisle > 0;
const cardDef = card ? layout[card.product.aisle] : def;
const neighbours = card ? products.filter((p) => p.aisle === card.product.aisle && p.bay === card.product.bay && p.side === card.product.side && p.id !== card.product.id).sort((x, y) => x.shelf - y.shelf) : [];
const across = card ? (card.product.side === "left" ? cardDef.bays[card.product.bay].right : cardDef.bays[card.product.bay].left) : "";
const ctrl = (label: string, icon: React.ReactNode, handlers: React.ButtonHTMLAttributes<HTMLButtonElement>, kbd?: string) => (
<button
type="button"
aria-label={label}
aria-keyshortcuts={kbd}
title={kbd ? `${label} (${kbd})` : label}
className={CTRL}
{...handlers}
>
{icon}
</button>
);
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate h-[760px] w-full select-none overflow-hidden bg-background text-foreground antialiased", ROOT_VARS, className)}>
{/* Scene */}
<div
ref={sceneRef}
className={cn("absolute inset-0 touch-none", view === "3d" && "cursor-grab active:cursor-grabbing")}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
inert={card || dirOpen ? true : undefined}
>
{geo && view === "3d" && (
<AisleScene
key={def.id}
aisle={def}
aisleIndex={aisle}
nextAisle={layout[aisle + 1]}
products={products}
geo={geo}
pos={pos}
yaw={yaw}
hiddenId={card?.product.id ?? null}
focusedId={focused?.id ?? null}
money={money}
onPick={noop}
/>
)}
{geo && view === "2d" && (
<FlatView
aisle={def}
aisleIndex={aisle}
aisleCount={layout.length}
bay={bay}
products={products}
width={geo.w}
height={geo.h}
money={money}
hiddenId={card?.product.id ?? null}
focusedId={focused?.id ?? null}
onPick={(p, el) => openCard(p, el)}
onStep={step}
/>
)}
<AnimatePresence>{fade && <motion.div key="fade" className="pointer-events-none absolute inset-0 bg-background" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.28 }} />}</AnimatePresence>
</div>
{/* HUD */}
<div className="pointer-events-none absolute inset-0 flex flex-col" inert={card || dirOpen ? true : undefined}>
<div className="flex items-start justify-between gap-2 p-3 sm:p-4">
<div className="pointer-events-auto min-w-0 rounded-2xl border bg-background/85 px-3 py-2 shadow-sm backdrop-blur">
<p className="flex items-center gap-1.5 text-[13px] font-semibold">
<span className="grid size-5 place-items-center rounded-md text-[10px] font-bold text-white" style={{ background: def.tint }} aria-hidden>
{aisle + 1}
</span>
<span className="truncate">
<span className="max-sm:hidden">{storeName} · </span>
{def.name}
</span>
</p>
<p className="mt-0.5 truncate text-[11.5px] text-muted-foreground tabular-nums">
Bay {bay + 1}/{bays} · {facing ? `facing ${facing === "left" ? b.left : b.right}` : `${b.left} | ${b.right}`}
</p>
</div>
<div className="flex shrink-0 items-start gap-1.5">
<div className="pointer-events-auto flex rounded-xl border bg-background/85 p-0.5 shadow-sm backdrop-blur" role="group" aria-label="View">
{(["3d", "2d"] as const).map((v) => (
<button
key={v}
type="button"
aria-pressed={view === v}
onClick={() => setViewPref(v)}
className={cn("h-9 rounded-[10px] px-2.5 text-[12px] font-semibold uppercase", view === v ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground", focusRing)}
>
{v === "3d" ? <Box className="mr-1 inline size-3.5 align-[-2px]" aria-hidden /> : null}
{v}
</button>
))}
</div>
<button type="button" onClick={() => setDirOpen(true)} aria-label="Store directory (list view)" className={cn("pointer-events-auto grid size-10 place-items-center rounded-xl border bg-background/85 shadow-sm backdrop-blur hover:bg-accent", focusRing)}>
<List className="size-4.5" aria-hidden />
</button>
<div className="relative">
<button
ref={basketBtn}
type="button"
onClick={() => setBasketOpen((o) => !o)}
aria-expanded={basketOpen}
aria-label={`Basket, ${count} item${count === 1 ? "" : "s"}, ${money(total)}`}
className={cn("pointer-events-auto flex h-10 items-center gap-1.5 rounded-xl border bg-background/85 px-2.5 text-[12.5px] font-semibold shadow-sm backdrop-blur hover:bg-accent", focusRing)}
>
<ShoppingBasket className="size-4.5" aria-hidden />
<motion.span key={count} initial={reduced ? false : { scale: 1.5 }} animate={{ scale: 1 }} className="tabular-nums">
{count}
</motion.span>
{count > 0 && <span className="hidden tabular-nums text-muted-foreground sm:inline">· {money(total)}</span>}
</button>
<AnimatePresence>{basketOpen && <BasketPopover key="basket" lines={basket} money={money} total={total} onClose={() => setBasketOpen(false)} onRemove={(id) => setBasket((l) => l.filter((x) => x.product.id !== id))} onCheckout={() => onCheckout?.(basket)} returnTo={basketBtn} />}</AnimatePresence>
</div>
</div>
</div>
{/* Minimap */}
<div className="pointer-events-none flex justify-end px-3 sm:px-4">
{mobile ? (
<div className="pointer-events-auto relative">
<button type="button" onClick={() => setMapOpen((o) => !o)} aria-expanded={mapOpen} className={cn("flex h-9 items-center gap-1.5 rounded-full border bg-background/85 px-3 text-[12px] font-semibold shadow-sm backdrop-blur", focusRing)}>
<MapIcon className="size-4" aria-hidden /> A{aisle + 1} · B{bay + 1}
</button>
{mapOpen && (
<div className="absolute right-0 top-11 rounded-2xl border bg-background/95 p-1 shadow-xl backdrop-blur">
<Minimap aisles={layout} aisle={aisle} pos={pos} yaw={yaw} target={target} onTeleport={(ai, bi) => { setMapOpen(false); teleport(ai, bi); }} />
</div>
)}
</div>
) : (
<div className="pointer-events-auto rounded-2xl border bg-background/85 p-1 shadow-sm backdrop-blur">
<Minimap aisles={layout} aisle={aisle} pos={pos} yaw={yaw} target={target} onTeleport={(ai, bi) => teleport(ai, bi)} />
</div>
)}
</div>
<div className="flex-1" />
{/* Intro */}
<AnimatePresence>
{intro && view === "3d" && (
<motion.p
key="intro"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="mx-auto mb-3 max-w-[90%] rounded-full bg-foreground/85 px-4 py-2 text-center text-[12px] font-medium text-background shadow-lg"
>
{mobile ? "Swipe up to walk · swipe sideways to look · tap a shelf" : "← → or drag to walk · Q / E to look · Enter picks · / to ask"}
</motion.p>
)}
</AnimatePresence>
{(atEnd || atStart) && (
<div className="pointer-events-auto mx-auto mb-2 flex gap-2">
{atStart && (
<button type="button" onClick={() => step(-1)} className={cn("inline-flex h-10 items-center gap-1.5 rounded-full border bg-background/90 px-4 text-[12.5px] font-semibold shadow-md backdrop-blur hover:bg-accent", focusRing)}>
<CornerUpLeft className="size-4" aria-hidden /> Back to aisle {aisle} · {layout[aisle - 1].name}
</button>
)}
{atEnd && (
<button type="button" onClick={() => step(1)} className={cn("inline-flex h-10 items-center gap-1.5 rounded-full px-4 text-[12.5px] font-semibold text-white shadow-md hover:brightness-110", focusRing)} style={{ background: layout[aisle + 1].tint }}>
Turn into aisle {aisle + 2} · {layout[aisle + 1].name} <CornerUpRight className="size-4" aria-hidden />
</button>
)}
</div>
)}
<div className="flex items-end gap-2 p-3 sm:p-4">
<Wayfinder
ref={askRef}
state={way}
aisles={layout}
suggestions={suggestions}
money={money}
onAsk={(q) => void ask(q)}
onPick={(p) => goTo(p)}
onClear={() => {
setWay({ status: "idle" });
setTarget(null);
}}
className="mx-auto w-full max-w-md"
/>
<div className="pointer-events-auto hidden shrink-0 grid-cols-3 gap-1 md:grid" role="group" aria-label="Walk controls">
<span />
<WalkButton label="Walk forward" kbd="ArrowUp" dir={1} onStart={startHold} onStop={stopHold} onStep={step} />
<span />
{ctrl("Look left", <RotateCcw className="size-4.5" aria-hidden />, { onClick: () => look(-1) }, "Q")}
<WalkButton label="Walk back" kbd="ArrowDown" dir={-1} onStart={startHold} onStop={stopHold} onStep={step} />
{ctrl("Look right", <RotateCw className="size-4.5" aria-hidden />, { onClick: () => look(1) }, "E")}
</div>
</div>
{mobile && (
<div className="pointer-events-auto flex justify-center gap-2 px-3 pb-3" role="group" aria-label="Walk controls">
{ctrl("Look left", <RotateCcw className="size-4.5" aria-hidden />, { onClick: () => look(-1) }, "Q")}
<WalkButton label="Walk back" kbd="ArrowDown" dir={-1} onStart={startHold} onStop={stopHold} onStep={step} />
<WalkButton label="Walk forward" kbd="ArrowUp" dir={1} onStart={startHold} onStop={stopHold} onStep={step} />
{ctrl("Look right", <RotateCw className="size-4.5" aria-hidden />, { onClick: () => look(1) }, "E")}
</div>
)}
</div>
{/* Overlays */}
<AnimatePresence>
{card && (
<React.Fragment key="card">
{mobile && <motion.div className="absolute inset-0 z-30 bg-black/35" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setCard(null)} aria-hidden />}
<ProductCard
key={card.product.id}
product={card.product}
aisle={cardDef}
from={card.from}
neighbours={neighbours}
across={across}
money={money}
inBasket={basket.find((l) => l.product.id === card.product.id)?.qty ?? 0}
onAdd={add}
onOpen={(p) => openCard(p)}
onClose={() => setCard(null)}
mobile={mobile}
/>
</React.Fragment>
)}
{dirOpen && (
<React.Fragment key="dir">
<motion.div className="absolute inset-0 z-40 bg-black/35" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setDirOpen(false)} aria-hidden />
<Directory
aisles={layout}
products={products}
current={{ aisle, bay }}
money={money}
onGo={(ai, bi) => {
setDirOpen(false);
teleport(ai, bi);
}}
onPick={(p) => {
setDirOpen(false);
goTo(p);
}}
onClose={() => setDirOpen(false)}
/>
</React.Fragment>
)}
</AnimatePresence>
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
</MotionConfig>
);
}
const CTRL = cn("grid size-11 place-items-center rounded-xl border bg-background/85 shadow-sm backdrop-blur transition active:scale-95 hover:bg-accent", focusRing);
/** Press-and-hold walk button: tap steps one bay, holding keeps walking. */
function WalkButton({ label, kbd, dir, onStart, onStop, onStep }: { label: string; kbd: string; dir: 1 | -1; onStart: (d: 1 | -1) => void; onStop: () => void; onStep: (d: 1 | -1) => void }) {
return (
<button
type="button"
aria-label={label}
aria-keyshortcuts={kbd}
title={`${label} (${dir === 1 ? "↑" : "↓"})`}
className={CTRL}
onPointerDown={(e) => {
e.preventDefault();
onStart(dir);
}}
onPointerUp={onStop}
onPointerLeave={onStop}
onPointerCancel={onStop}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !e.repeat) {
e.preventDefault();
e.stopPropagation();
onStep(dir);
}
}}
>
{dir === 1 ? <ChevronUp className="size-5" aria-hidden /> : <ChevronDown className="size-5" aria-hidden />}
</button>
);
}
function BasketPopover({ lines, money, total, onClose, onRemove, onCheckout, returnTo }: { lines: BasketLine[]; money: (v: number) => string; total: number; onClose: () => void; onRemove: (id: string) => void; onCheckout: () => void; returnTo: React.RefObject<HTMLButtonElement | null> }) {
const ref = React.useRef<HTMLDivElement>(null);
useDialog(true, ref, () => {
onClose();
returnTo.current?.focus();
});
return (
<motion.div
ref={ref}
role="dialog"
aria-label="Basket"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
className="pointer-events-auto absolute right-0 top-12 z-50 w-[min(300px,calc(100vw-24px))] rounded-2xl border bg-popover p-3 text-popover-foreground shadow-xl"
>
<div className="mb-2 flex items-center justify-between">
<p className="text-[13px] font-semibold">Basket</p>
<button type="button" onClick={onClose} aria-label="Close basket" className={cn("grid size-8 place-items-center rounded-full hover:bg-accent", focusRing)}>
<X className="size-4" aria-hidden />
</button>
</div>
{lines.length === 0 ? (
<p className="py-4 text-center text-[12.5px] text-muted-foreground">Pick something off a shelf.</p>
) : (
<>
<ul className="max-h-56 space-y-1 overflow-y-auto">
{lines.map((l) => (
<li key={l.product.id} className="flex items-center gap-2 text-[12.5px]">
<span className="w-6 shrink-0 text-right font-semibold tabular-nums">{l.qty}×</span>
<span className="min-w-0 flex-1 truncate">{l.product.name}</span>
<span className="shrink-0 tabular-nums">{money(l.qty * l.product.price)}</span>
<button type="button" onClick={() => onRemove(l.product.id)} aria-label={`Remove ${l.product.name}`} className={cn("grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground", focusRing)}>
<Trash2 className="size-3.5" aria-hidden />
</button>
</li>
))}
</ul>
<div className="mt-2 flex items-center justify-between border-t pt-2 text-[13px]">
<span className="text-muted-foreground">Total</span>
<span className="font-bold tabular-nums">{money(total)}</span>
</div>
<button type="button" onClick={onCheckout} className={cn("mt-2 h-10 w-full rounded-xl bg-[var(--wa-focus)] text-[13px] font-semibold text-white hover:brightness-110 dark:text-neutral-950", focusRing)}>
Click & collect
</button>
</>
)}
</motion.div>
);
}