Fazekit

Code

"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 &amp; collect
          </button>
        </>
      )}
    </motion.div>
  );
}

More in E-commerce

View all →