"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;