Fazekit

Code

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

More in E-commerce

View all →