Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, MotionConfig, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import {
  ChevronDown,
  Columns2,
  Flame,
  LayoutGrid,
  Loader2,
  MousePointer2,
  Move,
  Package,
  PanelRight,
  Redo2,
  Send,
  Sparkles,
  Undo2,
  X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { autoMerch, computeMetrics, DEFAULT_RULES, normalise, onShelf, shelfUsedCm, STRATEGIES } from "./auto-merch";
import { CATEGORY_ORDER, PLANOGRAMS, SKUS } from "./data";
import { Inspector } from "./inspector";
import { MetricsBar } from "./metrics-bar";
import { ProductPalette } from "./product-palette";
import { Facings, heatColor, ShadeDefs, ShelfCanvas, type Preview } from "./shelf-bay";
import type { HeatMetric, Mode, Placement, Planogram, Rules, Selection, Sku, Strategy, Target } from "./types";
import { useShelfDrag } from "./use-shelf-drag";

export type { Sku, Planogram, Placement, Rules };

export type PlanogramStudioAppProps = {
  skus?: Sku[];
  planograms?: Planogram[];
  initialPlanogramId?: string;
  /** Overrides every planogram's bay width. */
  bayWidthCm?: number;
  /** 0-based from the top. Default 1 (second shelf). */
  eyeLevelShelf?: number;
  currency?: string;
  locale?: string;
  rules?: Partial<Rules>;
  onChange?: (planogram: Planogram) => void;
  /** "Publish to stores" button. */
  onPublish?: (planogram: Planogram) => void;
  className?: string;
};

type History = { past: Placement[][]; future: Placement[][] };
type Kbd = { skuId: string; from: "palette" | "shelf"; target: Target; facings: number };

const focusRing = "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";

export function PlanogramStudioApp({
  skus = SKUS,
  planograms = PLANOGRAMS,
  initialPlanogramId,
  bayWidthCm,
  eyeLevelShelf = 1,
  currency = "PLN",
  locale = "pl-PL",
  rules: rulesProp,
  onChange,
  onPublish,
  className,
}: PlanogramStudioAppProps) {
  const reduced = useReducedMotion() ?? false;
  const rules: Rules = React.useMemo(() => ({ ...DEFAULT_RULES, ...rulesProp }), [rulesProp]);
  const eye = eyeLevelShelf;
  const money = React.useMemo(() => {
    const f = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 });
    return (v: number) => f.format(v);
  }, [locale, currency]);
  const skuMap = React.useMemo(() => new Map(skus.map((s) => [s.id, s])), [skus]);
  const categories = React.useMemo(() => {
    const cats = [...new Set(skus.map((s) => s.category))];
    return cats.sort((a, b) => (CATEGORY_ORDER.indexOf(a) + 1 || 99) - (CATEGORY_ORDER.indexOf(b) + 1 || 99));
  }, [skus]);

  const initial = React.useMemo(
    () => Object.fromEntries(planograms.map((p) => [p.id, { ...p, bayWidthCm: bayWidthCm ?? p.bayWidthCm, placements: normalise(p.placements) }])) as Record<string, Planogram>,
    [planograms, bayWidthCm],
  );
  const [versions, setVersions] = React.useState<Record<string, Planogram>>(initial);
  const [activeId, setActiveId] = React.useState(initialPlanogramId ?? planograms[0]?.id ?? "");
  const [history, setHistory] = React.useState<Record<string, History>>({});
  const [selection, setSelection] = React.useState<Selection>(null);
  const [cursor, setCursor] = React.useState<Target>({ bay: 0, shelf: 0, index: 0 });
  const [mode, setMode] = React.useState<Mode>("edit");
  const [heatMetric, setHeatMetric] = React.useState<HeatMetric>("velocity");
  const [armed, setArmed] = React.useState<{ skuId: string; from: "palette" | "shelf" } | null>(null);
  const [kbd, setKbd] = React.useState<Kbd | null>(null);
  const [wave, setWave] = React.useState(0);
  const [reject, setReject] = React.useState<{ key: string; n: number } | null>(null);
  const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
  const [announce, setAnnounce] = React.useState("");
  const [menuOpen, setMenuOpen] = React.useState(false);
  const [paletteOpen, setPaletteOpen] = React.useState(false);
  const [inspectorOpen, setInspectorOpen] = React.useState(false);
  const [showPlaced, setShowPlaced] = React.useState(false);
  const [activeBay, setActiveBay] = React.useState(0);
  const [cw, setCw] = React.useState(0);
  const [publishing, setPublishing] = React.useState(false);

  const rootRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLDivElement>(null);
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const gridHostRef = React.useRef<HTMLDivElement>(null);
  const pendingFocus = React.useRef(false);

  const pg = versions[activeId] ?? Object.values(versions)[0];
  const metrics = React.useMemo(() => computeMetrics(pg, skus, rules, eye), [pg, skus, rules, eye]);
  const baseline = React.useMemo(() => computeMetrics(initial[pg.id] ?? pg, skus, rules, eye), [initial, pg, skus, rules, eye]);
  const compareBase = versions[pg.id === "live" ? (planograms.find((p) => p.id !== "live")?.id ?? pg.id) : "live"] ?? pg;
  const compareMetrics = React.useMemo(() => computeMetrics(compareBase, skus, rules, eye), [compareBase, skus, rules, eye]);
  const unplaced = React.useMemo(() => new Set(skus.filter((s) => !pg.placements.some((p) => p.skuId === s.id)).map((s) => s.id)), [skus, pg]);
  const hist = history[pg.id] ?? { past: [], future: [] };
  const shelfHeightCm = Math.max(...skus.map((s) => s.heightCm)) + 4;

  /* ------------------------------ sizing ------------------------------ */

  React.useEffect(() => {
    const el = canvasRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([e]) => setCw(e.contentRect.width));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  const narrow = cw > 0 && cw < 600;
  const scale = cw === 0 ? 2 : narrow ? Math.min(2.5, (cw - 28) / pg.bayWidthCm) : Math.min(3.2, (cw - 32 - 14 * (pg.bays - 1)) / (pg.bays * pg.bayWidthCm));
  const gapPx = narrow ? Math.max(12, cw - 28 - pg.bayWidthCm * scale) : 14;

  /* ------------------------------ commits ----------------------------- */

  const onChangeRef = React.useRef(onChange);
  React.useLayoutEffect(() => {
    onChangeRef.current = onChange;
  });

  const commit = React.useCallback(
    (placements: Placement[], msg?: string) => {
      const id = pg.id;
      const next = { ...pg, placements: normalise(placements) };
      setHistory((h) => {
        const cur = h[id] ?? { past: [], future: [] };
        return { ...h, [id]: { past: [...cur.past.slice(-49), pg.placements], future: [] } };
      });
      setVersions((v) => ({ ...v, [id]: next }));
      onChangeRef.current?.(next);
      if (msg) setAnnounce(msg);
    },
    [pg],
  );

  const undo = () => {
    if (!hist.past.length) return;
    const prev = hist.past[hist.past.length - 1];
    setHistory((h) => ({ ...h, [pg.id]: { past: hist.past.slice(0, -1), future: [pg.placements, ...hist.future] } }));
    const next = { ...pg, placements: prev };
    setVersions((v) => ({ ...v, [pg.id]: next }));
    onChangeRef.current?.(next);
    setAnnounce("Undone");
  };
  const redo = () => {
    if (!hist.future.length) return;
    const [nextP, ...rest] = hist.future;
    setHistory((h) => ({ ...h, [pg.id]: { past: [...hist.past, pg.placements], future: rest } }));
    const next = { ...pg, placements: nextP };
    setVersions((v) => ({ ...v, [pg.id]: next }));
    onChangeRef.current?.(next);
    setAnnounce("Redone");
  };

  const showToast = (text: string) => setToast({ id: Date.now(), text });
  React.useEffect(() => {
    if (!toast) return;
    const t = window.setTimeout(() => setToast((c) => (c?.id === toast.id ? null : c)), 3600);
    return () => window.clearTimeout(t);
  }, [toast]);

  const describe = (t: Target) => `bay ${t.bay + 1}, shelf ${t.shelf + 1}${t.shelf === eye ? " (eye level)" : ""}, slot ${t.index + 1}`;
  const skuName = (id: string) => {
    const s = skuMap.get(id);
    return s ? `${s.brand} ${s.name}` : id;
  };
  const facingsFor = (skuId: string) => pg.placements.find((p) => p.skuId === skuId)?.facings ?? rules.minFacings;
  const widthFor = (skuId: string, f = facingsFor(skuId)) => (skuMap.get(skuId)?.widthCm ?? 0) * f;
  const canDrop = (skuId: string, t: Target, f = facingsFor(skuId)) => shelfUsedCm(pg.placements, skuMap, t.bay, t.shelf, skuId) + widthFor(skuId, f) <= pg.bayWidthCm + rules.maxOverfillCm + 1e-6;

  const placeAt = (skuId: string, t: Target, f = facingsFor(skuId)) => {
    const rest = pg.placements.filter((p) => p.skuId !== skuId);
    const shelfItems = onShelf(rest, t.bay, t.shelf);
    const others = rest.filter((p) => !(p.bay === t.bay && p.shelf === t.shelf));
    const idx = Math.max(0, Math.min(t.index, shelfItems.length));
    const list = [...shelfItems];
    list.splice(idx, 0, { skuId, bay: t.bay, shelf: t.shelf, index: idx, facings: f });
    commit([...others, ...list.map((p, i) => ({ ...p, index: i }))], `Dropped ${skuName(skuId)} on ${describe({ ...t, index: idx })}`);
    setSelection({ kind: "sku", skuId });
    setCursor({ bay: t.bay, shelf: t.shelf, index: idx });
  };
  const removeSku = (skuId: string) => {
    commit(
      pg.placements.filter((p) => p.skuId !== skuId),
      `${skuName(skuId)} moved back to the palette`,
    );
    setSelection((s) => (s?.kind === "sku" && s.skuId === skuId ? null : s));
  };
  const setFacings = (skuId: string, f: number) => {
    const n = Math.max(1, Math.min(16, f));
    commit(
      pg.placements.map((p) => (p.skuId === skuId ? { ...p, facings: n } : p)),
      `${skuName(skuId)}: ${n} facings`,
    );
  };
  const doReject = (skuId: string, t: Target) => {
    const free = pg.bayWidthCm - shelfUsedCm(pg.placements, skuMap, t.bay, t.shelf, skuId);
    setReject((r) => ({ key: `${t.bay}-${t.shelf}`, n: (r?.n ?? 0) + 1 }));
    const msg = `Doesn't fit: needs ${Math.round(widthFor(skuId) * 10) / 10} cm, ${Math.max(0, Math.round(free * 10) / 10)} cm free`;
    showToast(msg);
    setAnnounce(msg);
  };

  /* ---------------------------- pointer drag --------------------------- */

  const { drag, x, y, begin, consumeClick } = useShelfDrag({
    rootRef,
    reducedMotion: reduced,
    canDrop: (id, t) => canDrop(id, t),
    onDrop: (id, t) => placeAt(id, t),
    onRemove: removeSku,
    onReject: doReject,
    onAnnounce: setAnnounce,
    describe,
  });

  const preview: Preview = drag
    ? { skuId: drag.skuId, target: drag.over, widthCm: widthFor(drag.skuId), valid: drag.valid }
    : kbd
      ? { skuId: kbd.skuId, target: kbd.target, widthCm: widthFor(kbd.skuId, kbd.facings), valid: canDrop(kbd.skuId, kbd.target, kbd.facings) }
      : null;
  const hiddenSku = (drag?.from === "shelf" && !drag.rejecting ? drag.skuId : null) ?? (kbd?.from === "shelf" ? kbd.skuId : null);

  /* ------------------------------ mobile bays -------------------------- */

  const scrollToBay = (b: number) => {
    setActiveBay(b);
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTo({ left: b * (pg.bayWidthCm * scale + gapPx), behavior: reduced ? "auto" : "smooth" });
  };

  /* --------------------------- keyboard move --------------------------- */

  const shelfCount = (b: number, s: number, exclude?: string) => onShelf(pg.placements, b, s).filter((p) => p.skuId !== exclude).length;

  const pickUp = (skuId: string, from: "palette" | "shelf", at?: Target) => {
    const target = at ?? { bay: cursor.bay, shelf: cursor.shelf, index: shelfCount(cursor.bay, cursor.shelf, skuId) };
    setKbd({ skuId, from, target, facings: facingsFor(skuId) });
    setArmed(null);
    setAnnounce(`Picked up ${skuName(skuId)}. Over ${describe(target)}. Arrow keys move, Enter drops, Escape cancels.`);
    if (from === "shelf") requestAnimationFrame(() => gridHostRef.current?.focus({ preventScroll: true }));
  };

  const kbdRef = React.useRef(kbd);
  React.useLayoutEffect(() => {
    kbdRef.current = kbd;
  });
  const kbdHandlers = React.useRef<(e: KeyboardEvent) => void>(() => {});
  React.useLayoutEffect(() => {
    kbdHandlers.current = (e: KeyboardEvent) => {
      const k = kbdRef.current;
      if (!k) return;
      const t = { ...k.target };
      const n = (b: number, s: number) => shelfCount(b, s, k.skuId);
      if (e.key === "ArrowUp" || e.key === "ArrowDown") {
        t.shelf = Math.max(0, Math.min(pg.shelvesPerBay - 1, t.shelf + (e.key === "ArrowUp" ? -1 : 1)));
        t.index = Math.min(t.index, n(t.bay, t.shelf));
      } else if (e.key === "ArrowLeft") {
        if (t.index > 0) t.index--;
        else if (t.bay > 0) {
          t.bay--;
          t.index = n(t.bay, t.shelf);
        }
      } else if (e.key === "ArrowRight") {
        if (t.index < n(t.bay, t.shelf)) t.index++;
        else if (t.bay < pg.bays - 1) {
          t.bay++;
          t.index = 0;
        }
      } else if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        e.stopPropagation();
        if (canDrop(k.skuId, t, k.facings)) {
          setKbd(null);
          placeAt(k.skuId, t, k.facings);
          pendingFocus.current = true;
        } else doReject(k.skuId, t);
        return;
      } else if (e.key === "Escape") {
        e.preventDefault();
        e.stopPropagation();
        setKbd(null);
        setAnnounce("Move cancelled.");
        pendingFocus.current = true;
        return;
      } else return;
      e.preventDefault();
      e.stopPropagation();
      setKbd({ ...k, target: t });
      if (narrow && t.bay !== k.target.bay) scrollToBay(t.bay);
      setAnnounce(`Over ${describe(t)}${canDrop(k.skuId, t, k.facings) ? "" : ", does not fit"}`);
    };
  });
  React.useEffect(() => {
    if (!kbd) return;
    const onKey = (e: KeyboardEvent) => kbdHandlers.current(e);
    window.addEventListener("keydown", onKey, true);
    return () => window.removeEventListener("keydown", onKey, true);
  }, [kbd]);

  // Move DOM focus to the cursor cell after keyboard navigation / drops.
  React.useEffect(() => {
    if (!pendingFocus.current) return;
    pendingFocus.current = false;
    requestAnimationFrame(() => {
      const el = rootRef.current?.querySelector<HTMLElement>(`[data-cell="pg-${cursor.bay}-${cursor.shelf}-${cursor.index}"]`) ?? rootRef.current?.querySelector<HTMLElement>(`[data-cell^="pg-${cursor.bay}-${cursor.shelf}-"]`);
      el?.focus({ preventScroll: false });
    });
  });

  const onCellKeyDown = (e: React.KeyboardEvent<HTMLElement>, t: Target, skuId: string | null) => {
    if (kbd) return;
    const count = (b: number, s: number) => shelfCount(b, s);
    const next = { ...t };
    switch (e.key) {
      case "ArrowLeft":
        if (t.index > 0) next.index--;
        else if (t.bay > 0) {
          next.bay--;
          next.index = Math.max(0, count(next.bay, t.shelf) - 1);
        }
        break;
      case "ArrowRight":
        if (t.index < count(t.bay, t.shelf) - 1) next.index++;
        else if (t.bay < pg.bays - 1) {
          next.bay++;
          next.index = 0;
        }
        break;
      case "ArrowUp":
      case "ArrowDown":
        next.shelf = Math.max(0, Math.min(pg.shelvesPerBay - 1, t.shelf + (e.key === "ArrowUp" ? -1 : 1)));
        next.index = Math.min(t.index, Math.max(0, count(t.bay, next.shelf) - 1));
        break;
      case "Enter":
      case " ":
        if (skuId) {
          e.preventDefault();
          pickUp(skuId, "shelf", { ...t });
        }
        return;
      case "Delete":
      case "Backspace":
        if (skuId) {
          e.preventDefault();
          removeSku(skuId);
          pendingFocus.current = true;
          setCursor({ ...t, index: Math.max(0, t.index - 1) });
        }
        return;
      case "+":
      case "=":
        if (skuId) {
          e.preventDefault();
          setFacings(skuId, facingsFor(skuId) + 1);
        }
        return;
      case "-":
      case "_":
        if (skuId) {
          e.preventDefault();
          setFacings(skuId, facingsFor(skuId) - 1);
        }
        return;
      default:
        return;
    }
    e.preventDefault();
    setCursor(next);
    if (narrow && next.bay !== t.bay) scrollToBay(next.bay);
    pendingFocus.current = true;
  };

  /* ------------------------------ global keys -------------------------- */

  const globalRef = React.useRef<(e: KeyboardEvent) => void>(() => {});
  React.useLayoutEffect(() => {
    globalRef.current = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement;
      const root = rootRef.current;
      if (!root || !(root.contains(t) || t === document.body) || kbd) return;
      const typing = Boolean(t.closest("input,textarea,select,[contenteditable]"));
      const mod = e.ctrlKey || e.metaKey;
      if (mod && e.key.toLowerCase() === "z") {
        e.preventDefault();
        if (e.shiftKey) redo();
        else undo();
      } else if (mod && e.key.toLowerCase() === "y") {
        e.preventDefault();
        redo();
      } else if (!typing && !mod && e.key.toLowerCase() === "h") {
        setMode((m) => (m === "heatmap" ? "edit" : "heatmap"));
      } else if (e.key === "Escape") {
        if (armed) {
          setArmed(null);
          setAnnounce("Placement cancelled");
        } else if (menuOpen) setMenuOpen(false);
      }
    };
  });
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => globalRef.current(e);
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  /* ------------------------------ auto-merch --------------------------- */

  const runMerch = (s: Strategy) => {
    setMenuOpen(false);
    const before = metrics;
    const placements = autoMerch(pg, skus, s, rules, eye);
    const after = computeMetrics({ ...pg, placements }, skus, rules, eye);
    setWave((w) => w + 1);
    commit(placements);
    setSelection(null);
    const pct = (a: number, b: number) => `${a >= b ? "+" : "−"}${Math.abs(Math.round(((a - b) / Math.max(1, Math.abs(b))) * 100))}%`;
    const msg =
      s === "margin"
        ? `Margin per metre ${pct(after.marginPerMetre, before.marginPerMetre)}`
        : s === "sales"
          ? `Projected sales ${pct(after.weeklySales, before.weeklySales)} · OOS risk ${before.oosRisk} → ${after.oosRisk}`
          : s === "brand"
            ? `Brand runs ${before.brandRuns} → ${after.brandRuns} (tighter blocks)`
            : `Compliance ${Math.round(before.compliancePct)}% → ${Math.round(after.compliancePct)}%`;
    showToast(`${STRATEGIES.find((x) => x.id === s)?.label}: ${msg}`);
    setAnnounce(`Auto-merchandised. ${msg}. Compliance ${Math.round(after.compliancePct)} percent.`);
    window.setTimeout(() => setWave(0), 1600);
  };

  const publish = async () => {
    setPublishing(true);
    try {
      await Promise.resolve(onPublish?.(pg));
      await new Promise((r) => setTimeout(r, 700));
      showToast(`${pg.name} published to stores`);
    } finally {
      setPublishing(false);
    }
  };

  /* ------------------------------ tap-to-place ------------------------- */

  const shelfIndexAt = (bay: number, shelf: number, clientX: number, exclude: string) => {
    const el = canvasRef.current?.querySelector<HTMLElement>(`[data-shelf="${bay}-${shelf}"]`);
    if (!el) return 0;
    const slots = Array.from(el.querySelectorAll<HTMLElement>("[data-slot]")).filter((s) => s.dataset.slot !== exclude);
    return slots.filter((s) => {
      const r = s.getBoundingClientRect();
      return clientX > r.left + r.width / 2;
    }).length;
  };

  const onShelfClick = (bay: number, shelf: number, clientX: number) => {
    if (consumeClick()) return;
    if (armed) {
      const t = { bay, shelf, index: shelfIndexAt(bay, shelf, clientX, armed.skuId) };
      if (canDrop(armed.skuId, t)) {
        placeAt(armed.skuId, t);
        setArmed(null);
      } else doReject(armed.skuId, t);
      return;
    }
    setSelection({ kind: "shelf", bay, shelf });
  };

  const armFromPalette = (s: Sku) => {
    if (consumeClick()) return;
    setArmed((a) => (a?.skuId === s.id ? null : { skuId: s.id, from: "palette" }));
    setSelection({ kind: "sku", skuId: s.id });
    setPaletteOpen(false);
    setAnnounce(`${s.brand} ${s.name} selected. Tap a shelf to place it.`);
  };

  /* ------------------------------ compare split ------------------------ */

  const split = useMotionValue(50);
  const clip = useTransform(split, (v) => `inset(0 ${100 - v}% 0 0)`);
  const splitLeft = useTransform(split, (v) => `${v}%`);
  const [splitVal, setSplitVal] = React.useState(50);
  const setSplit = (v: number) => {
    const n = Math.max(0, Math.min(100, v));
    split.set(n);
    setSplitVal(Math.round(n));
  };
  const compareHostRef = React.useRef<HTMLDivElement>(null);
  const onSplitPointer = (e: React.PointerEvent<HTMLElement>) => {
    const host = compareHostRef.current;
    if (!host) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    const upd = (cx: number) => {
      const r = host.getBoundingClientRect();
      setSplit(((cx - r.left) / r.width) * 100);
    };
    upd(e.clientX);
    const el = e.currentTarget;
    const mv = (ev: PointerEvent) => upd(ev.clientX);
    const up = () => {
      el.removeEventListener("pointermove", mv);
      el.removeEventListener("pointerup", up);
    };
    el.addEventListener("pointermove", mv);
    el.addEventListener("pointerup", up);
  };

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

  const heat = mode === "heatmap" ? heatMetric : null;
  const heatVals = pg.placements.map((p) => (heatMetric === "velocity" ? (metrics.sku[p.skuId]?.units ?? 0) : (skuMap.get(p.skuId)?.marginPct ?? 0)));
  const heatRange: [number, number] = heatVals.length ? [Math.min(...heatVals), Math.max(...heatVals)] : [0, 1];
  const selectedSku = selection?.kind === "sku" ? skuMap.get(selection.skuId) : undefined;
  const selectedPlacement = selectedSku ? pg.placements.find((p) => p.skuId === selectedSku.id) : undefined;
  const ghostSku = drag ? skuMap.get(drag.skuId) : undefined;
  const modal = paletteOpen || inspectorOpen;

  const canvasCommon = {
    skus: skuMap,
    scale,
    eye,
    heat,
    heatRange,
    gapPx,
    shelfHeightCm,
  };

  const canvasBody = (
    <ShelfCanvas
      pg={pg}
      metrics={metrics}
      {...canvasCommon}
      interactive
      idPrefix="pg"
      selection={selection}
      cursor={cursor}
      preview={preview}
      hiddenSku={hiddenSku}
      armed={Boolean(armed)}
      wave={wave}
      reject={reject}
      onPlacementPointerDown={(e, id) => {
        if (mode === "compare") return;
        begin(e, id, "shelf", widthFor(id));
      }}
      onCellFocus={(t, id) => {
        setCursor(t);
        if (id && !kbd) setSelection({ kind: "sku", skuId: id });
      }}
      onCellKeyDown={onCellKeyDown}
      onCellClick={(t, id) => {
        if (consumeClick()) return;
        setCursor(t);
        setSelection({ kind: "sku", skuId: id });
      }}
      onShelfClick={onShelfClick}
    />
  );

  return (
    <MotionConfig reducedMotion="user">
      <div
        ref={rootRef}
        className={cn("relative isolate flex h-[760px] w-full select-none flex-col overflow-hidden bg-background text-foreground antialiased", drag && "cursor-grabbing", className)}
      >
        <ShadeDefs />
        <div inert={modal ? true : undefined} className="flex min-h-0 flex-1 flex-col">
          {/* Header */}
          <header className="flex h-12 shrink-0 items-center gap-2.5 border-b px-3 sm:px-4">
            <span aria-hidden className="grid size-8 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-md shadow-emerald-600/25">
              <LayoutGrid className="size-4" />
            </span>
            <div className="hidden min-w-0 leading-tight sm:block">
              <h1 className="truncate text-sm font-semibold tracking-tight">Planogram Studio</h1>
              <p className="truncate text-[11px] text-muted-foreground">Aisle 7 · Wine, beer & soft drinks · 3 bays</p>
            </div>
            <h1 className="sr-only sm:hidden">Planogram Studio</h1>
            <div role="tablist" aria-label="Versions" className="ml-1 flex items-center gap-1 rounded-xl bg-muted/70 p-0.5 sm:ml-4">
              {Object.values(versions).map((v) => {
                const on = v.id === pg.id;
                return (
                  <button
                    key={v.id}
                    type="button"
                    role="tab"
                    aria-selected={on}
                    onClick={() => {
                      setActiveId(v.id);
                      setSelection(null);
                      setKbd(null);
                      setArmed(null);
                      setCursor({ bay: 0, shelf: 0, index: 0 });
                    }}
                    className={cn("relative h-7 rounded-lg px-2.5 text-[12px] font-medium transition-colors", on ? "text-foreground" : "text-muted-foreground hover:text-foreground", focusRing)}
                  >
                    {on && <motion.span layoutId="pg-version" className="absolute inset-0 rounded-lg bg-background shadow-sm ring-1 ring-border" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
                    <span className="relative flex items-center gap-1.5">
                      {v.id === "live" && <span className="size-1.5 rounded-full bg-emerald-500" aria-hidden />}
                      {v.name}
                    </span>
                  </button>
                );
              })}
            </div>
            <button
              type="button"
              onClick={() => void publish()}
              disabled={publishing}
              className={cn("ml-auto inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-2.5 text-[12.5px] font-semibold text-primary-foreground shadow-sm hover:bg-primary/90 disabled:opacity-70 sm:px-3", focusRing)}
            >
              {publishing ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : <Send className="size-3.5" aria-hidden />}
              <span className="hidden sm:inline">Publish to stores</span>
              <span className="sr-only sm:hidden">Publish to stores</span>
            </button>
          </header>

          <div className="shrink-0 border-b px-3 py-2 sm:px-4">
            <MetricsBar metrics={metrics} baseline={baseline} money={money} />
          </div>

          {/* Toolbar */}
          <div className="relative z-20 flex h-11 shrink-0 items-center gap-1.5 border-b px-3 sm:px-4">
            <div role="radiogroup" aria-label="Mode" className="flex items-center gap-0.5 rounded-lg border p-0.5">
              {(
                [
                  ["edit", "Edit", <MousePointer2 key="i" className="size-3.5" aria-hidden />],
                  ["heatmap", "Heatmap", <Flame key="i" className="size-3.5" aria-hidden />],
                  ["compare", "Compare", <Columns2 key="i" className="size-3.5" aria-hidden />],
                ] as const
              ).map(([id, label, icon]) => (
                <button
                  key={id}
                  type="button"
                  role="radio"
                  aria-checked={mode === id}
                  onClick={() => setMode(id)}
                  title={id === "heatmap" ? "Heatmap (H)" : label}
                  className={cn(
                    "inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium transition-colors",
                    mode === id ? "bg-foreground text-background" : "text-muted-foreground hover:bg-muted hover:text-foreground",
                    focusRing,
                  )}
                >
                  {icon}
                  <span className="hidden md:inline">{label}</span>
                  <span className="sr-only md:hidden">{label}</span>
                </button>
              ))}
            </div>
            {mode === "heatmap" && (
              <div role="radiogroup" aria-label="Heatmap metric" className="flex items-center gap-0.5 rounded-lg bg-muted/70 p-0.5">
                {(["velocity", "margin"] as const).map((m) => (
                  <button
                    key={m}
                    type="button"
                    role="radio"
                    aria-checked={heatMetric === m}
                    onClick={() => setHeatMetric(m)}
                    className={cn("h-7 rounded-md px-2 text-[11.5px] font-medium capitalize", heatMetric === m ? "bg-background shadow-sm" : "text-muted-foreground", focusRing)}
                  >
                    {m}
                  </button>
                ))}
              </div>
            )}
            <div className="relative">
              <button
                type="button"
                aria-haspopup="menu"
                aria-expanded={menuOpen}
                onClick={() => setMenuOpen((o) => !o)}
                className={cn(
                  "inline-flex h-8 items-center gap-1.5 whitespace-nowrap rounded-lg bg-gradient-to-r from-emerald-500 to-teal-600 px-2.5 text-[12.5px] font-semibold text-white shadow-sm shadow-emerald-600/25 hover:brightness-105",
                  focusRing,
                )}
              >
                <Sparkles className="size-3.5" aria-hidden />
                <span className="hidden sm:inline">Auto-merchandise</span>
                <span className="sm:hidden">Auto</span>
                <ChevronDown className={cn("size-3.5 transition-transform", menuOpen && "rotate-180")} aria-hidden />
              </button>
              <AnimatePresence>{menuOpen && <MerchMenu onPick={runMerch} onClose={() => setMenuOpen(false)} />}</AnimatePresence>
            </div>
            <div className="flex items-center">
              <button type="button" onClick={undo} disabled={!hist.past.length} aria-label="Undo (Ctrl+Z)" title="Undo (Ctrl+Z)" className={cn("grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-35", focusRing)}>
                <Undo2 className="size-4" />
              </button>
              <button type="button" onClick={redo} disabled={!hist.future.length} aria-label="Redo (Ctrl+Shift+Z)" title="Redo (Ctrl+Shift+Z)" className={cn("grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-35", focusRing)}>
                <Redo2 className="size-4" />
              </button>
            </div>
            <div className="ml-auto hidden min-w-0 items-center gap-2 text-[11.5px] text-muted-foreground lg:flex">
              {mode === "heatmap" ? (
                <HeatLegend metric={heatMetric} range={heatRange} />
              ) : mode === "compare" ? (
                <span>
                  Drag the divider · <span className="font-medium text-foreground">{compareBase.name}</span> vs <span className="font-medium text-foreground">{pg.name}</span>
                </span>
              ) : kbd ? (
                <span className="font-medium text-primary">Moving {skuName(kbd.skuId)} · arrows · Enter drop · Esc cancel</span>
              ) : armed ? (
                <span className="font-medium text-primary">Click a shelf to place {skuName(armed.skuId)} · Esc cancels</span>
              ) : (
                <span className="truncate">Drag products onto shelves · + / − facings · Del removes · H heatmap</span>
              )}
            </div>
          </div>

          {/* Body */}
          <div className="flex min-h-0 flex-1">
            <aside aria-label="Product palette" className="hidden w-64 shrink-0 border-r bg-muted/20 lg:flex">
              <ProductPalette
                className="w-full"
                skus={skus}
                unplaced={unplaced}
                categories={categories}
                armedId={armed?.skuId ?? null}
                carryingId={drag?.from === "palette" ? drag.skuId : kbd?.from === "palette" ? kbd.skuId : null}
                money={money}
                onPointerDown={(e, s) => begin(e, s.id, "palette", widthFor(s.id))}
                onArm={armFromPalette}
                onKeyboardPickUp={(s) => pickUp(s.id, pg.placements.some((p) => p.skuId === s.id) ? "shelf" : "palette")}
                showPlaced={showPlaced}
                onShowPlaced={setShowPlaced}
              />
            </aside>

            <main className="flex min-w-0 flex-1 flex-col">
              {narrow && (
                <div role="tablist" aria-label="Bays" className="flex shrink-0 gap-1 border-b px-3 py-1.5">
                  {Array.from({ length: pg.bays }, (_, b) => {
                    const st = [0, 1, 2, 3, 4].map((s) => metrics.shelf[`${b}-${s}`]).filter(Boolean);
                    const bad = st.some((s) => s.over > 0);
                    return (
                      <button
                        key={b}
                        type="button"
                        role="tab"
                        aria-selected={activeBay === b}
                        onClick={() => scrollToBay(b)}
                        className={cn("relative h-8 flex-1 rounded-lg text-[12px] font-semibold", activeBay === b ? "bg-foreground text-background" : "bg-muted/60 text-muted-foreground", focusRing)}
                      >
                        Bay {b + 1}
                        {bad && <span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-red-500" aria-label="has overfilled shelf" />}
                      </button>
                    );
                  })}
                </div>
              )}
              <div ref={canvasRef} className="relative min-h-0 flex-1">
                {mode === "compare" ? (
                  <div className="absolute inset-0 overflow-auto p-4">
                    <div ref={compareHostRef} className="relative mx-auto w-max">
                      <div aria-hidden>
                        <ShelfCanvas pg={pg} metrics={metrics} {...canvasCommon} interactive={false} idPrefix="cmp-after" />
                      </div>
                      <motion.div aria-hidden className="absolute inset-0 bg-background" style={{ clipPath: clip }}>
                        <ShelfCanvas pg={compareBase} metrics={compareMetrics} {...canvasCommon} interactive={false} idPrefix="cmp-before" />
                      </motion.div>
                      <motion.div className="absolute inset-y-0 z-10 -ml-px w-0.5 bg-primary" style={{ left: splitLeft }}>
                        <span className="absolute left-1/2 top-1 -translate-x-[calc(100%+8px)] whitespace-nowrap rounded-md bg-foreground/85 px-1.5 py-0.5 text-[10.5px] font-semibold text-background">
                          {compareBase.name} · {Math.round(compareMetrics.compliancePct)}%
                        </span>
                        <span className="absolute left-1/2 top-1 translate-x-2 whitespace-nowrap rounded-md bg-primary px-1.5 py-0.5 text-[10.5px] font-semibold text-primary-foreground">
                          {pg.name} · {Math.round(metrics.compliancePct)}%
                        </span>
                        <button
                          type="button"
                          role="slider"
                          aria-label={`Compare divider: ${compareBase.name} on the left, ${pg.name} on the right`}
                          aria-valuemin={0}
                          aria-valuemax={100}
                          aria-valuenow={splitVal}
                          onPointerDown={onSplitPointer}
                          onKeyDown={(e) => {
                            const step = e.shiftKey ? 10 : 2;
                            if (e.key === "ArrowLeft") setSplit(split.get() - step);
                            else if (e.key === "ArrowRight") setSplit(split.get() + step);
                            else if (e.key === "Home") setSplit(0);
                            else if (e.key === "End") setSplit(100);
                            else return;
                            e.preventDefault();
                          }}
                          className={cn("absolute left-1/2 top-1/2 grid h-12 w-7 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none place-items-center rounded-full border-2 border-primary bg-background shadow-lg", focusRing)}
                        >
                          <Columns2 className="size-3.5 text-primary" aria-hidden />
                        </button>
                      </motion.div>
                    </div>
                  </div>
                ) : (
                  <div
                    ref={(el) => {
                      scrollRef.current = el;
                    }}
                    onScroll={(e) => {
                      if (!narrow) return;
                      const el = e.currentTarget;
                      const b = Math.round(el.scrollLeft / (pg.bayWidthCm * scale + gapPx));
                      if (b !== activeBay && b >= 0 && b < pg.bays) setActiveBay(b);
                    }}
                    className={cn("absolute inset-0 overflow-auto overscroll-contain", narrow ? "snap-x snap-mandatory px-3.5 py-3" : "p-4")}
                  >
                    <div ref={gridHostRef} tabIndex={-1} className={cn("relative outline-none", narrow ? "w-max [&_[data-bay]]:snap-center" : "mx-auto w-max")}>
                      <LayoutGroup id="pg-canvas">{canvasBody}</LayoutGroup>
                    </div>
                  </div>
                )}
              </div>
            </main>

            <aside aria-label="Inspector" className="hidden w-72 shrink-0 border-l bg-muted/20 xl:flex">
              <Inspector className="w-full" selection={selection} pg={pg} skus={skuMap} metrics={metrics} eye={eye} money={money} onFacings={setFacings} onRemove={removeSku} onSelect={setSelection} />
            </aside>
          </div>

          {/* Mobile / tablet action bar */}
          <div className="flex h-14 shrink-0 items-center gap-2 border-t bg-card px-3 xl:hidden">
            <button
              type="button"
              onClick={() => setPaletteOpen(true)}
              className={cn("inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xl border px-3 text-[12.5px] font-semibold lg:hidden", focusRing)}
            >
              <Package className="size-4" aria-hidden /> Products
              {unplaced.size > 0 && <span className="rounded-full bg-primary px-1.5 text-[10px] font-bold text-primary-foreground">{unplaced.size}</span>}
            </button>
            {armed ? (
              <div className="flex min-w-0 flex-1 items-center gap-2 rounded-xl bg-primary/10 px-3 py-2 text-[12px]">
                <span className="min-w-0 flex-1 truncate font-medium text-primary">Tap a shelf to place {skuMap.get(armed.skuId)?.name}</span>
                <button type="button" onClick={() => setArmed(null)} aria-label="Cancel placement" className={cn("grid size-7 place-items-center rounded-md hover:bg-background/60", focusRing)}>
                  <X className="size-3.5" />
                </button>
              </div>
            ) : selectedSku ? (
              <div className="flex min-w-0 flex-1 items-center gap-1.5">
                <span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">{selectedSku.name}</span>
                {selectedPlacement && (
                  <>
                    <button type="button" onClick={() => setFacings(selectedSku.id, selectedPlacement.facings - 1)} aria-label="Fewer facings" className={cn("grid size-9 place-items-center rounded-lg border text-base font-bold", focusRing)}>

                    </button>
                    <span className="w-6 text-center text-sm font-bold tabular-nums">{selectedPlacement.facings}</span>
                    <button type="button" onClick={() => setFacings(selectedSku.id, selectedPlacement.facings + 1)} aria-label="More facings" className={cn("grid size-9 place-items-center rounded-lg border text-base font-bold", focusRing)}>
                      +
                    </button>
                    <button type="button" onClick={() => setArmed({ skuId: selectedSku.id, from: "shelf" })} aria-label={`Move ${selectedSku.name}`} className={cn("grid size-9 place-items-center rounded-lg border", focusRing)}>
                      <Move className="size-4" />
                    </button>
                  </>
                )}
              </div>
            ) : (
              <span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">{metrics.warnings.length} rule issues · tap a product or shelf</span>
            )}
            <button type="button" onClick={() => setInspectorOpen(true)} aria-label="Open inspector" className={cn("grid size-10 shrink-0 place-items-center rounded-xl border", focusRing)}>
              <PanelRight className="size-4" />
            </button>
          </div>
        </div>

        {/* Drag ghost */}
        {drag && ghostSku && (
          <motion.div data-drag-ghost aria-hidden className="pointer-events-none absolute left-0 top-0 z-50" style={{ x, y }}>
            <motion.div
              initial={{ scale: 1, rotate: 0 }}
              animate={{ scale: 1.06, rotate: drag.rejecting ? 0 : 2 }}
              className={cn("rounded-md p-0.5 shadow-2xl", drag.over && !drag.valid ? "bg-red-500/25 ring-2 ring-red-500" : "bg-background/70 ring-2 ring-primary")}
            >
              <Facings sku={ghostSku} facings={facingsFor(drag.skuId)} scale={scale} />
            </motion.div>
          </motion.div>
        )}

        {/* Toast */}
        <div className="pointer-events-none absolute inset-x-0 bottom-16 z-[60] flex justify-center px-4 xl:bottom-5">
          <AnimatePresence>
            {toast && (
              <motion.div
                key={toast.id}
                role="status"
                initial={{ opacity: 0, y: 12 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: 8 }}
                className="pointer-events-auto max-w-full rounded-xl bg-foreground px-4 py-2.5 text-[13px] font-medium text-background shadow-xl"
              >
                {toast.text}
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        <p aria-live="assertive" className="sr-only">
          {announce}
        </p>

        <Sheet open={paletteOpen} onClose={() => setPaletteOpen(false)} label="Products">
          <ProductPalette
            className="h-full"
            skus={skus}
            unplaced={unplaced}
            categories={categories}
            armedId={armed?.skuId ?? null}
            carryingId={null}
            money={money}
            onPointerDown={() => {}}
            onArm={armFromPalette}
            onKeyboardPickUp={(s) => {
              setPaletteOpen(false);
              pickUp(s.id, pg.placements.some((p) => p.skuId === s.id) ? "shelf" : "palette");
            }}
            showPlaced={showPlaced}
            onShowPlaced={setShowPlaced}
          />
        </Sheet>
        <Sheet open={inspectorOpen} onClose={() => setInspectorOpen(false)} label="Inspector">
          <Inspector
            className="h-full"
            selection={selection}
            pg={pg}
            skus={skuMap}
            metrics={metrics}
            eye={eye}
            money={money}
            onFacings={setFacings}
            onRemove={(id) => {
              removeSku(id);
              setInspectorOpen(false);
            }}
            onSelect={setSelection}
            onClose={() => setInspectorOpen(false)}
          />
        </Sheet>
      </div>
    </MotionConfig>
  );
}

/* ------------------------------------------------------------------ */

function HeatLegend({ metric, range }: { metric: HeatMetric; range: [number, number] }) {
  const fmt = (v: number) => (metric === "velocity" ? `${Math.round(v)} u/wk` : `${Math.round(v)}%`);
  return (
    <span className="flex items-center gap-2" aria-label={`Heatmap legend: ${metric}, from ${fmt(range[0])} (cool) to ${fmt(range[1])} (hot)`}>
      <span className="tabular-nums">{fmt(range[0])}</span>
      <span className="h-2 w-28 rounded-full" style={{ background: `linear-gradient(to right, ${[0, 0.25, 0.5, 0.75, 1].map((t) => heatColor(t)).join(",")})` }} aria-hidden />
      <span className="tabular-nums">{fmt(range[1])}</span>
      <span className="text-foreground">{metric === "velocity" ? "projected units / week" : "margin %"}</span>
    </span>
  );
}

function MerchMenu({ onPick, onClose }: { onPick: (s: Strategy) => void; onClose: () => void }) {
  const ref = React.useRef<HTMLDivElement>(null);
  React.useEffect(() => {
    ref.current?.querySelector<HTMLElement>("[role=menuitem]")?.focus();
    const down = (e: PointerEvent) => {
      if (!ref.current?.contains(e.target as Node) && !(e.target as HTMLElement).closest("[aria-haspopup=menu]")) onClose();
    };
    window.addEventListener("pointerdown", down);
    return () => window.removeEventListener("pointerdown", down);
  }, [onClose]);
  return (
    <motion.div
      ref={ref}
      role="menu"
      aria-label="Auto-merchandise strategy"
      initial={{ opacity: 0, y: -4, scale: 0.97 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      exit={{ opacity: 0, y: -4, scale: 0.97 }}
      transition={{ duration: 0.14 }}
      onKeyDown={(e) => {
        const items = Array.from(ref.current?.querySelectorAll<HTMLElement>("[role=menuitem]") ?? []);
        const i = items.indexOf(document.activeElement as HTMLElement);
        if (e.key === "ArrowDown") {
          e.preventDefault();
          items[(i + 1) % items.length]?.focus();
        } else if (e.key === "ArrowUp") {
          e.preventDefault();
          items[(i - 1 + items.length) % items.length]?.focus();
        } else if (e.key === "Escape") {
          e.stopPropagation();
          onClose();
        }
      }}
      className="absolute left-0 top-10 z-30 w-72 max-w-[calc(100vw-2rem)] rounded-2xl border bg-popover p-1.5 text-popover-foreground shadow-xl"
    >
      {STRATEGIES.map((s) => (
        <button key={s.id} type="button" role="menuitem" onClick={() => onPick(s.id)} className="flex w-full flex-col items-start rounded-xl px-3 py-2 text-left outline-none hover:bg-muted focus-visible:bg-muted">
          <span className="text-[13px] font-semibold">{s.label}</span>
          <span className="text-[11.5px] text-muted-foreground">{s.hint}</span>
        </button>
      ))}
    </motion.div>
  );
}

function Sheet({ open, onClose, label, children }: { open: boolean; onClose: () => void; label: string; children: React.ReactNode }) {
  return <AnimatePresence>{open && <SheetBody onClose={onClose} label={label}>{children}</SheetBody>}</AnimatePresence>;
}

function SheetBody({ onClose, label, children }: { onClose: () => void; label: string; children: React.ReactNode }) {
  const ref = React.useRef<HTMLDivElement>(null);
  React.useEffect(() => {
    const prev = document.activeElement as HTMLElement | null;
    requestAnimationFrame(() => ref.current?.querySelector<HTMLElement>("input,button,[tabindex='0']")?.focus());
    return () => {
      requestAnimationFrame(() => prev?.isConnected && prev.focus({ preventScroll: true }));
    };
  }, []);
  return (
    <>
      <motion.div aria-hidden onClick={onClose} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 z-40 bg-zinc-950/40 dark:bg-black/60" />
      <motion.div
        ref={ref}
        role="dialog"
        aria-modal="true"
        aria-label={label}
        initial={{ y: "100%" }}
        animate={{ y: 0 }}
        exit={{ y: "100%" }}
        transition={{ type: "spring", stiffness: 400, damping: 40 }}
        onKeyDown={(e) => {
          if (e.key === "Escape") {
            e.stopPropagation();
            onClose();
          }
          if (e.key === "Tab" && ref.current) {
            const f = Array.from(ref.current.querySelectorAll<HTMLElement>("button:not([disabled]),input,[tabindex='0']"));
            if (!f.length) return;
            if (e.shiftKey && document.activeElement === f[0]) {
              e.preventDefault();
              f[f.length - 1].focus();
            } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) {
              e.preventDefault();
              f[0].focus();
            }
          }
        }}
        className="absolute inset-x-0 bottom-0 z-50 flex h-[78%] flex-col rounded-t-3xl border-t bg-background shadow-2xl"
      >
        <div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-muted-foreground/25" aria-hidden />
        {children}
      </motion.div>
    </>
  );
}

export default PlanogramStudioApp;

More in E-commerce

View all →