"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { CalendarDays, Check, LineChart, List, Printer, Tag, ZoomIn } from "lucide-react";
import { cn } from "@/lib/utils";
import { ACCENTS, BASE_DATE, PRODUCTS, STORE_NAME } from "./data";
import { Inspector } from "./inspector";
import { labelDescription, makeMoney, PX_PER_MM, ScaledLabel } from "./price-label";
import { PriceHistory } from "./price-history";
import { PrintSheet } from "./print-sheet";
import { ProductList, type ListFilter } from "./product-list";
import { checks as runChecks, isReduction, paginate, suggestedPrice, TEMPLATE_META, TEMPLATES, worst } from "./rules";
import type { CheckStatus, LabelFields, LabelTemplate, Product, QueueItem } from "./types";
import { focusRing, Kbd, LumenMark, ROOT_VARS } from "./ui";
export type { Product, LabelTemplate, QueueItem, LabelFields };
export type ShelfLabelStudioAppProps = {
products?: Product[];
storeName?: string;
currency?: string;
locale?: string;
/** ISO date treated as "today" for promo end-date checks. */
today?: string;
defaultTemplate?: LabelTemplate;
defaultFields?: Partial<LabelFields>;
accentColors?: string[];
/** Length of the lowest-price window for reductions (EU rule: 30). */
lowestPriceDays?: number;
onPrint?: (queue: QueueItem[], pages: number) => void;
onProductChange?: (product: Product) => void;
className?: string;
};
type Tab = "products" | "label" | "sheet";
type Zoom = "fit" | 1 | 2;
const addDays = (iso: string, n: number) => {
const [y, m, d] = iso.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d) + n * 86_400_000).toISOString().slice(0, 10);
};
let qseq = 0;
const mq = "(min-width: 1024px)";
function useIsDesktop() {
return React.useSyncExternalStore(
(cb) => {
const m = window.matchMedia(mq);
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
},
() => window.matchMedia(mq).matches,
() => true,
);
}
function Ruler({ mm, px, vertical }: { mm: number; px: number; vertical?: boolean }) {
const ticks = Array.from({ length: Math.floor(mm / 5) + 1 }, (_, i) => i * 5);
const len = mm * px;
return (
<svg aria-hidden width={vertical ? 18 : len} height={vertical ? len : 18} className="block shrink-0 overflow-visible text-muted-foreground">
{ticks.map((t) => {
const pos = t * px;
const major = t % 10 === 0;
return vertical ? <line key={t} x1={18} x2={major ? 8 : 12} y1={pos} y2={pos} stroke="currentColor" strokeOpacity={0.6} /> : <line key={t} y1={18} y2={major ? 8 : 12} x1={pos} x2={pos} stroke="currentColor" strokeOpacity={0.6} />;
})}
{vertical ? (
<text x={4} y={len / 2} className="fill-current text-[10px]" transform={`rotate(-90 4 ${len / 2})`} textAnchor="middle" dy="0.35em">
{mm} mm
</text>
) : (
<text x={len / 2} y={4} className="fill-current text-[10px]" textAnchor="middle" dy="0.35em">
{mm} mm
</text>
)}
</svg>
);
}
export function ShelfLabelStudioApp({
products: initialProducts = PRODUCTS,
storeName = STORE_NAME,
currency = "PLN",
locale = "pl-PL",
today = BASE_DATE,
defaultTemplate = "standard",
defaultFields,
accentColors = ACCENTS,
lowestPriceDays = 30,
onPrint,
onProductChange,
className,
}: ShelfLabelStudioAppProps) {
const reduced = useReducedMotion() ?? false;
const money = React.useMemo(() => makeMoney(currency, locale), [currency, locale]);
const [products, setProducts] = React.useState<Product[]>(initialProducts);
const [templates, setTemplates] = React.useState<Record<string, LabelTemplate>>(() => Object.fromEntries(initialProducts.map((p) => [p.id, p.promo ? "promo" : defaultTemplate])));
const [fields, setFields] = React.useState<LabelFields>({ unitPrice: true, origin: true, barcode: true, sku: false, lowest: true, ...defaultFields });
const [accent, setAccent] = React.useState(accentColors[0]);
const [selectedId, setSelectedId] = React.useState(initialProducts[0]?.id ?? "");
const [checked, setChecked] = React.useState<Set<string>>(new Set());
const [filter, setFilter] = React.useState<ListFilter>("all");
const [query, setQuery] = React.useState("");
const [queue, setQueue] = React.useState<QueueItem[]>([]);
const [fresh, setFresh] = React.useState<Set<string>>(new Set());
const [rawTab, setTab] = React.useState<Tab>("products");
const isDesktop = useIsDesktop();
// Desktop shows the product list next to the label, so "products" and "label" are the same view.
const tab: Tab = isDesktop && rawTab === "products" ? "label" : rawTab;
const [zoom, setZoom] = React.useState<Zoom>("fit");
const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
const [bump, setBump] = React.useState(0);
const [canvasW, setCanvasW] = React.useState(620);
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLDivElement>(null);
const byId = React.useMemo(() => Object.fromEntries(products.map((p) => [p.id, p])), [products]);
const product = byId[selectedId] ?? products[0];
const template = templates[product.id] ?? defaultTemplate;
const checkOpts = React.useMemo(() => ({ days: lowestPriceDays, today, money: money.text }), [lowestPriceDays, today, money]);
const currentChecks = runChecks(product, template, fields, checkOpts);
const statuses = React.useMemo(() => {
const out: Record<string, CheckStatus> = {};
for (const p of products) out[p.id] = worst(runChecks(p, templates[p.id], fields, checkOpts));
return out;
}, [products, templates, fields, checkOpts]);
const queuedIds = React.useMemo(() => new Set(queue.map((q) => q.productId)), [queue]);
const labelCount = queue.reduce((a, q) => a + q.copies, 0);
const pageCount = paginate(queue).length;
React.useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setCanvasW(e.contentRect.width));
ro.observe(el);
return () => ro.disconnect();
}, [tab]);
const say = (text: string) => setToast({ id: Date.now(), text });
React.useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
React.useEffect(() => {
if (fresh.size === 0) return;
const t = window.setTimeout(() => setFresh(new Set()), 2500);
return () => window.clearTimeout(t);
}, [fresh]);
/* -------------------------------- actions ------------------------------- */
const updateProduct = (id: string, fn: (p: Product) => Product) => {
const cur = byId[id];
if (!cur) return;
const next = fn(cur);
setProducts((ps) => ps.map((p) => (p.id === id ? next : p)));
onProductChange?.(next);
};
const setTemplate = (t: LabelTemplate, id = product.id) => {
const p = byId[id];
if (!p) return;
if (isReduction(t) && !p.promo) updateProduct(id, (x) => ({ ...x, promo: { price: suggestedPrice(x, t), until: addDays(today, 7) } }));
setTemplates((m) => ({ ...m, [id]: t }));
};
const togglePromo = (on: boolean) => setTemplate(on ? "promo" : "standard");
const focusRowSoon = (id: string) => window.setTimeout(() => rootRef.current?.querySelector<HTMLElement>(`[data-row="${id}"]`)?.focus(), 30);
const openLabel = (id: string) => {
setSelectedId(id);
if (!isDesktop) setTab("label");
window.setTimeout(() => rootRef.current?.querySelector<HTMLElement>('[aria-label="Label template"] [aria-checked="true"]')?.focus(), 40);
};
const addToSheet = () => {
const ids = checked.size ? products.filter((p) => checked.has(p.id)).map((p) => p.id) : [product.id];
const next = queue.map((q) => ({ ...q }));
const freshKeys = new Set<string>();
for (const id of ids) {
const t = templates[id];
const existing = next.find((q) => q.productId === id && q.template === t);
if (existing) {
existing.copies = Math.min(99, existing.copies + 1);
freshKeys.add(`${existing.id}-${existing.copies - 1}`);
} else {
const item = { id: `q${++qseq}`, productId: id, template: t, copies: 1 };
next.push(item);
freshKeys.add(`${item.id}-0`);
}
}
setQueue(next);
setFresh(freshKeys);
setChecked(new Set());
setTab("sheet");
setBump((b) => b + 1);
say(`Queued ${ids.length} ${ids.length === 1 ? "label" : "labels"}`);
};
const print = () => {
if (!queue.length) return;
const n = paginate(queue).length;
onPrint?.(queue, n);
say(`Sent ${n} ${n === 1 ? "sheet" : "sheets"} (${labelCount} labels) to the printer`);
};
const selectProduct = (id: string, via: "pointer" | "keyboard") => {
setSelectedId(id);
if (via === "pointer" && !isDesktop) setTab("label");
};
/* ------------------------------- shortcuts ------------------------------ */
React.useEffect(() => {
const root = rootRef.current;
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (!root || (t && t !== document.body && !root.contains(t))) return;
const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "p" && tab === "sheet") {
e.preventDefault();
print();
return;
}
if (e.key === "Escape") {
if (toast) setToast(null);
if (tab === "sheet") setTab("label");
return;
}
if (typing || e.metaKey || e.ctrlKey || e.altKey) return;
if (tab === "sheet") return;
const idx = ["1", "2", "3", "4"].indexOf(e.key);
if (idx >= 0) {
e.preventDefault();
setTemplate(TEMPLATES[idx]);
} else if (e.key.toLowerCase() === "p") {
e.preventDefault();
const on = template !== "promo";
togglePromo(on);
// Keyboard users go straight to the promo price.
if (on) {
// The promo fields animate in; retry briefly until the input exists.
let tries = 0;
const focusPrice = () => {
const el = rootRef.current?.querySelector<HTMLInputElement>("#sl-promo-price");
if (el) {
el.focus();
el.select();
} else if (tries++ < 12) window.setTimeout(focusPrice, 50);
};
window.setTimeout(focusPrice, 30);
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
/* --------------------------------- canvas ------------------------------- */
const meta = TEMPLATE_META[template];
const labelPxW = meta.w * PX_PER_MM;
const fitScale = Math.max(0.6, Math.min(2.4, (canvasW - 64) / labelPxW));
const scale = zoom === "fit" ? fitScale : Math.min(zoom, fitScale * 1.6);
const mmPx = PX_PER_MM * scale;
const canvas = (
<div className="flex min-h-0 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div role="radiogroup" aria-label="Label template" className="flex rounded-xl bg-muted p-1">
{TEMPLATES.map((t) => (
<button
key={t}
type="button"
role="radio"
aria-checked={template === t}
onClick={() => setTemplate(t)}
className={cn("relative inline-flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium transition-colors", template === t ? "text-foreground" : "text-muted-foreground hover:text-foreground", focusRing)}
>
{template === t && <motion.span layoutId="sl-tpl-pill" className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<span className="relative">{TEMPLATE_META[t].label}</span>
<span className="relative hidden text-[10px] text-muted-foreground xl:inline">{TEMPLATE_META[t].key}</span>
</button>
))}
</div>
<div className="ml-auto flex items-center gap-1 rounded-xl bg-muted p-1" role="group" aria-label="Zoom">
<ZoomIn className="mx-1 size-3.5 text-muted-foreground" aria-hidden />
{(["fit", 1, 2] as Zoom[]).map((z) => (
<button
key={String(z)}
type="button"
aria-pressed={zoom === z}
onClick={() => setZoom(z)}
className={cn("h-7 rounded-lg px-2 text-[12px] font-medium", zoom === z ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground", focusRing)}
>
{z === "fit" ? "Fit" : `${z}×`}
</button>
))}
</div>
</div>
<div
ref={canvasRef}
className="relative grid min-h-[260px] place-items-center overflow-hidden rounded-2xl border bg-muted/40 bg-[radial-gradient(circle,var(--color-border)_1px,transparent_1px)] [background-size:14px_14px] p-6 sm:p-8"
>
<div className="flex flex-col items-start">
<div className="ml-[22px]">
<Ruler mm={meta.w} px={mmPx} />
</div>
<div className="mt-1 flex items-start gap-1">
<Ruler mm={meta.h} px={mmPx} vertical />
<motion.div
key={`${template}`}
initial={reduced ? false : { opacity: 0.4, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 24 }}
className="rounded-[4px] shadow-[0_1px_2px_rgba(0,0,0,0.12),0_18px_40px_-16px_rgba(0,0,0,0.45)]"
role="img"
aria-label={`Label preview: ${labelDescription(product, template, fields, money, lowestPriceDays)}`}
>
<ScaledLabel scale={scale} product={product} template={template} fields={fields} accent={accent} money={money} lowestDays={lowestPriceDays} />
</motion.div>
</div>
</div>
<p className="absolute bottom-2 right-3 text-[11px] text-muted-foreground tabular-nums">
{meta.w} × {meta.h} mm · {Math.round(scale * 100 * (96 / 25.4 / PX_PER_MM))}%
</p>
</div>
<section className="rounded-2xl border bg-card p-3.5" aria-labelledby="sl-history">
<h3 id="sl-history" className="mb-1 flex items-center gap-2 text-[12.5px] font-semibold">
<LineChart className="size-3.5 text-muted-foreground" aria-hidden />
Price history · {lowestPriceDays} days
<span className="ml-auto font-normal text-muted-foreground">{product.category}</span>
</h3>
<PriceHistory product={product} promoPrice={isReduction(template) ? product.promo?.price : undefined} money={money} days={lowestPriceDays} />
</section>
</div>
);
const inspector = (
<Inspector
product={product}
template={template}
fields={fields}
accent={accent}
accents={accentColors}
checks={currentChecks}
money={money}
lowestDays={lowestPriceDays}
onFields={setFields}
onAccent={setAccent}
onTogglePromo={togglePromo}
onEscape={() => {
if (!isDesktop) setTab("products");
focusRowSoon(product.id);
}}
onPromo={(patch) => updateProduct(product.id, (p) => ({ ...p, promo: { price: patch.price ?? p.promo?.price ?? suggestedPrice(p, template), until: patch.until ?? p.promo?.until ?? addDays(today, 7) } }))}
/>
);
const list = (
<ProductList
products={products}
templates={templates}
statuses={statuses}
queued={queuedIds}
selectedId={product.id}
checked={checked}
filter={filter}
query={query}
accent={accent}
money={money}
onFilter={setFilter}
onQuery={setQuery}
onSelect={selectProduct}
onToggle={(id) =>
setChecked((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
})
}
onToggleAll={(ids, on) =>
setChecked((s) => {
const n = new Set(s);
ids.forEach((id) => (on ? n.add(id) : n.delete(id)));
return n;
})
}
onAdd={addToSheet}
onOpen={openLabel}
/>
);
const sheetOpen = tab === "sheet";
const TABS: { id: Tab; label: string; icon: React.ComponentType<{ className?: string }>; mobileOnly?: boolean }[] = [
{ id: "products", label: "Products", icon: List, mobileOnly: true },
{ id: "label", label: "Label", icon: Tag },
{ id: "sheet", label: "Sheet", icon: Printer },
];
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", ROOT_VARS, className)}>
<header className="flex h-14 shrink-0 items-center gap-2.5 border-b px-3 sm:px-4">
<LumenMark />
<div className="hidden min-w-0 leading-tight sm:block">
<h1 className="truncate text-sm font-semibold tracking-tight">Shelf labels</h1>
<p className="flex items-center gap-1 truncate text-[11px] text-muted-foreground">
{storeName} <CalendarDays className="size-3" aria-hidden /> {money.date(today)}
</p>
</div>
<div role="tablist" aria-label="View" className="flex rounded-xl bg-muted p-1 sm:ml-3">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={cn(
"relative inline-flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium transition-colors",
t.mobileOnly && "lg:hidden",
tab === t.id ? "text-foreground" : "text-muted-foreground hover:text-foreground",
focusRing,
)}
>
{tab === t.id && <motion.span layoutId="sl-view-pill" className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<t.icon className="relative size-3.5" aria-hidden />
<span className="relative">{t.label}</span>
{t.id === "sheet" && labelCount > 0 && (
<motion.span
key={bump}
initial={bump && !reduced ? { scale: 1.8 } : false}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14 }}
className="relative grid h-4.5 min-w-4.5 place-items-center rounded-full bg-rose-600 px-1 text-[10.5px] font-semibold text-white tabular-nums"
>
{labelCount}
<span className="sr-only"> labels queued</span>
</motion.span>
)}
</button>
))}
</div>
<div className="ml-auto hidden items-center gap-1.5 text-[11.5px] text-muted-foreground xl:flex">
<Kbd>1–4</Kbd> template <Kbd>P</Kbd> promo <Kbd>Esc</Kbd> back
</div>
{sheetOpen && queue.length > 0 && (
<button type="button" onClick={print} className={cn("ml-auto inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3 text-[12.5px] font-semibold text-primary-foreground xl:ml-2", focusRing)}>
<Printer className="size-3.5" aria-hidden />
<span className="hidden sm:inline">Print</span> {pageCount}
</button>
)}
</header>
<div className="min-h-0 flex-1">
{sheetOpen ? (
<PrintSheet
queue={queue}
products={byId}
fields={fields}
accent={accent}
money={money}
lowestDays={lowestPriceDays}
fresh={fresh}
onCopies={(id, c) => setQueue((q) => q.map((x) => (x.id === id ? { ...x, copies: Math.max(1, Math.min(99, c)) } : x)))}
onRemove={(id) => setQueue((q) => q.filter((x) => x.id !== id))}
onClear={() => {
setQueue([]);
say("Queue cleared");
}}
onPrint={print}
/>
) : (
<div className="h-full lg:grid lg:grid-cols-[300px_minmax(0,1fr)_320px]">
<aside aria-label="Products" className={cn("h-full min-h-0 border-r bg-muted/20", tab === "products" ? "block" : "hidden lg:block")}>
{list}
</aside>
<main className={cn("h-full min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-4", tab === "label" ? "block" : "hidden lg:block")}>
<div className="mb-3 flex items-baseline gap-2 lg:hidden">
<h2 className="min-w-0 truncate text-[14px] font-semibold">{product.name}</h2>
</div>
{canvas}
{!isDesktop && <div className="mt-3">{inspector}</div>}
<div className="h-16 lg:hidden" aria-hidden />
</main>
{isDesktop && (
<aside aria-label="Label settings" className="h-full min-h-0 overflow-y-auto overscroll-contain border-l p-3">
{inspector}
</aside>
)}
{tab === "label" && (
<div className="absolute inset-x-0 bottom-0 z-10 border-t bg-background/90 p-2.5 backdrop-blur lg:hidden">
<button
type="button"
onClick={addToSheet}
className={cn("flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-primary text-[13px] font-semibold text-primary-foreground", focusRing)}
>
<Printer className="size-4" aria-hidden /> Add this label to sheet
</button>
</div>
)}
</div>
)}
</div>
<AnimatePresence>
{toast && (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10 }}
className="pointer-events-none absolute bottom-20 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full bg-foreground px-4 py-2 text-[12.5px] font-medium text-background shadow-xl lg:bottom-6"
>
<Check className="size-4" aria-hidden /> {toast.text}
</motion.div>
)}
</AnimatePresence>
<p className="sr-only" aria-live="polite">
{toast?.text ?? ""}
</p>
</div>
</MotionConfig>
);
}