Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { CloudUpload, IdCard, Keyboard, ReceiptText, ShoppingBasket, Wifi, WifiOff, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { CATEGORIES, PRODUCTS, SEED_SALES, computeTicket, makeMoney, toMinor, uid } from "./data";
import { OfflineQueue } from "./offline-queue";
import { PaymentSheet, type Tender } from "./payment-sheet";
import { Initials, Kbd, Modal, RollingText, focusRing } from "./pos-ui";
import { ProductGrid } from "./product-grid";
import { Receipt } from "./receipt";
import { TicketPane } from "./ticket-pane";
import type { Computed } from "./data";
import type { Category, Discount, ParkedTicket, Product, Sale, TicketLine } from "./types";

export type { Product, Sale, TicketLine, Category };

export type PosRegisterAppProps = {
  products?: Product[];
  categories?: Category[];
  /** Completed sales already in today's shift. */
  initialSales?: Sale[];
  storeName?: string;
  storeAddress?: string;
  tillName?: string;
  cashier?: { name: string };
  currency?: string;
  locale?: string;
  /** Controlled connection state. When omitted, an internal Online/Offline toggle is shown. */
  online?: boolean;
  ageCheckLabel?: string;
  minAge?: number;
  onSaleComplete?: (sale: Sale) => void;
  /** Called with the queued sales when the till comes back online. Reject to keep them queued. */
  onSync?: (sales: Sale[]) => Promise<void> | void;
  onAgeCheck?: (confirmed: boolean, product: Product) => void;
  className?: string;
};

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export function PosRegisterApp({
  products = PRODUCTS,
  categories = CATEGORIES,
  initialSales = SEED_SALES,
  storeName = "Vinea Market",
  storeAddress = "12 Harbour Lane, Northvale",
  tillName = "Till 2",
  cashier = { name: "Ola Nowak" },
  currency = "PLN",
  locale = "pl-PL",
  online: onlineProp,
  ageCheckLabel,
  minAge = 18,
  onSaleComplete,
  onSync,
  onAgeCheck,
  className,
}: PosRegisterAppProps) {
  const money = React.useMemo(() => makeMoney(locale, currency), [locale, currency]);
  const [lines, setLines] = React.useState<TicketLine[]>([]);
  const [discount, setDiscount] = React.useState<Discount | null>(null);
  const [ageVerified, setAgeVerified] = React.useState(false);
  const [pendingAge, setPendingAge] = React.useState<string | null>(null);
  const [sales, setSales] = React.useState<Sale[]>(initialSales);
  const [offlineIds, setOfflineIds] = React.useState<Set<string>>(() => new Set());
  const [parked, setParked] = React.useState<ParkedTicket[]>([]);
  const [sold, setSold] = React.useState<Record<string, number>>({});
  const [flash, setFlash] = React.useState<{ productId: string; n: number } | null>(null);
  const [payOpen, setPayOpen] = React.useState(false);
  const [ticketOpen, setTicketOpen] = React.useState(false);
  const [queueOpen, setQueueOpen] = React.useState(false);
  const [helpOpen, setHelpOpen] = React.useState(false);
  const [receipt, setReceipt] = React.useState<{ sale: Sale; computed: Computed } | null>(null);
  const [toast, setToast] = React.useState<{ id: number; text: string; undo?: () => void } | null>(null);
  const [announce, setAnnounce] = React.useState("");
  const [internalOnline, setInternalOnline] = React.useState(true);
  const online = onlineProp ?? internalOnline;
  const canToggle = onlineProp === undefined;

  const rootRef = React.useRef<HTMLDivElement>(null);
  const scanRef = React.useRef<HTMLInputElement>(null);
  const payRef = React.useRef<HTMLButtonElement>(null);
  const mobilePayRef = React.useRef<HTMLButtonElement>(null);
  const flashN = React.useRef(0);
  const parkN = React.useRef(0);

  const stocked = React.useMemo(() => products.map((p) => (sold[p.id] ? { ...p, stock: Math.max(0, p.stock - sold[p.id]) } : p)), [products, sold]);
  const computed = React.useMemo(() => computeTicket(lines, products, discount), [lines, products, discount]);
  const qtyById = React.useMemo(() => Object.fromEntries(lines.map((l) => [l.productId, l.qty])), [lines]);
  const nextNumber = sales.reduce((m, s) => Math.max(m, s.number), 1000) + 1;
  const shiftTotal = sales.reduce((s, x) => s + toMinor(x.total), 0);
  const pendingCount = sales.filter((s) => offlineIds.has(s.id) && s.status !== "synced").length;
  const currencySymbol = React.useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency }).formatToParts(0).find((p) => p.type === "currency")?.value ?? currency,
    [locale, currency],
  );
  const anyModal = payOpen || ticketOpen || queueOpen || helpOpen || Boolean(receipt) || Boolean(pendingAge);

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

  /* ----------------------------- ticket ops ----------------------------- */

  const addProduct = React.useCallback(
    (p: Product) => {
      setLines((ls) => {
        const i = ls.findIndex((l) => l.productId === p.id);
        if (i < 0) return [...ls, { productId: p.id, qty: 1 }];
        return ls.map((l, j) => (j === i ? { ...l, qty: l.qty + 1 } : l));
      });
      flashN.current += 1;
      setFlash({ productId: p.id, n: flashN.current });
      if (p.ageRestricted && !ageVerified) setPendingAge((cur) => cur ?? p.id);
    },
    [ageVerified],
  );

  const setQty = (productId: string, qty: number) => {
    if (qty <= 0) return removeLine(productId);
    setLines((ls) => ls.map((l) => (l.productId === productId ? { ...l, qty: Math.min(qty, 999) } : l)));
  };

  const removeLine = (productId: string) => {
    const idx = lines.findIndex((l) => l.productId === productId);
    const line = lines[idx];
    if (!line) return;
    setLines((ls) => ls.filter((l) => l.productId !== productId));
    const name = products.find((p) => p.id === productId)?.name ?? "Item";
    showToast(`Removed ${name}`, () =>
      setLines((ls) => {
        if (ls.some((l) => l.productId === productId)) return ls;
        const copy = [...ls];
        copy.splice(Math.min(idx, copy.length), 0, line);
        return copy;
      }),
    );
  };

  const resetTicket = () => {
    setLines([]);
    setDiscount(null);
    setAgeVerified(false);
    setPendingAge(null);
  };

  const park = () => {
    if (!lines.length) return;
    parkN.current += 1;
    const letter = String.fromCharCode(64 + (((parkN.current - 1) % 26) + 1));
    setParked((p) => [...p, { id: uid("park"), label: `${letter} · ${computed.items} items · ${money(computed.total)}`, lines, discount, ageVerified }]);
    resetTicket();
    showToast(`Ticket ${letter} parked`);
  };

  const resume = (id: string) => {
    const t = parked.find((x) => x.id === id);
    if (!t) return;
    setParked((p) => {
      const rest = p.filter((x) => x.id !== id);
      if (!lines.length) return rest;
      parkN.current += 1;
      const letter = String.fromCharCode(64 + (((parkN.current - 1) % 26) + 1));
      return [...rest, { id: uid("park"), label: `${letter} · ${computed.items} items · ${money(computed.total)}`, lines, discount, ageVerified }];
    });
    setLines(t.lines);
    setDiscount(t.discount);
    setAgeVerified(t.ageVerified);
    setPendingAge(null);
    setAnnounce(`Resumed ticket ${t.label}`);
  };

  const openPay = () => {
    if (!lines.length || anyModal) return;
    if (computed.hasRestricted && !ageVerified) {
      setPendingAge(computed.rows.find((r) => r.product.ageRestricted)?.product.id ?? null);
      return;
    }
    setPayOpen(true);
  };

  const completeSale = (tender: Tender) => {
    const sale: Sale = {
      id: uid("sale"),
      number: nextNumber,
      lines,
      ...(discount ? { discount } : {}),
      total: computed.total / 100,
      tender: { card: tender.card / 100, cash: tender.cash / 100, change: tender.change / 100 },
      at: new Date().toISOString(),
      status: online ? "synced" : "queued",
    };
    setSales((s) => [...s, sale]);
    if (!online) setOfflineIds((s) => new Set(s).add(sale.id));
    setSold((s) => {
      const n = { ...s };
      for (const l of lines) n[l.productId] = (n[l.productId] ?? 0) + l.qty;
      return n;
    });
    setPayOpen(false);
    setReceipt({ sale, computed });
    onSaleComplete?.(sale);
    if (!online) setAnnounce(`Sale ${sale.number} queued offline`);
  };

  const newSale = () => {
    setReceipt(null);
    resetTicket();
    requestAnimationFrame(() => scanRef.current?.focus({ preventScroll: true }));
  };

  /* ----------------------------- age check ------------------------------ */

  const ageProduct = pendingAge ? products.find((p) => p.id === pendingAge) ?? null : null;
  const resolveAge = (confirmed: boolean) => {
    if (!ageProduct) return;
    if (confirmed) {
      setAgeVerified(true);
      setAnnounce(`Age confirmed for this ticket`);
    } else {
      setLines((ls) => ls.filter((l) => l.productId !== ageProduct.id));
      setAnnounce(`${ageProduct.name} removed`);
    }
    onAgeCheck?.(confirmed, ageProduct);
    setPendingAge(null);
  };

  /* ------------------------------- sync --------------------------------- */

  const salesRef = React.useRef(sales);
  const onSyncRef = React.useRef(onSync);
  React.useLayoutEffect(() => {
    salesRef.current = sales;
    onSyncRef.current = onSync;
  });

  React.useEffect(() => {
    if (!online) return;
    const queued = salesRef.current.filter((s) => s.status === "queued");
    if (!queued.length) return;
    let cancelled = false;
    const setStatus = (id: string, status: Sale["status"]) => setSales((all) => all.map((s) => (s.id === id ? { ...s, status } : s)));
    (async () => {
      await sleep(250);
      const pending = Promise.resolve().then(() => onSyncRef.current?.(queued));
      for (const s of queued) {
        if (cancelled) return;
        setStatus(s.id, "syncing");
        await sleep(400);
        if (cancelled) return;
        setStatus(s.id, "synced");
      }
      try {
        await pending;
        if (!cancelled) {
          setToast({ id: Date.now(), text: `Synced ${queued.length} ${queued.length === 1 ? "sale" : "sales"}` });
          setAnnounce(`Back online. ${queued.length} queued ${queued.length === 1 ? "sale" : "sales"} synced`);
        }
      } catch {
        if (cancelled) return;
        for (const s of queued) setStatus(s.id, "queued");
        setToast({ id: Date.now(), text: "Sync failed — sales stay queued" });
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [online]);

  const toggleOnline = () => {
    if (!canToggle) return;
    setInternalOnline((o) => !o);
    setAnnounce(online ? "Till is offline. Sales will be queued." : "Till is back online.");
  };

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

  const handlers = React.useRef({ openPay, park, help: () => setHelpOpen(true) });
  React.useLayoutEffect(() => {
    handlers.current = { openPay, park, help: () => setHelpOpen(true) };
  });
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const root = rootRef.current;
      const t = e.target as HTMLElement;
      if (!root || !(root.contains(t) || t === document.body)) return;
      if (t.closest('[role="dialog"],[role="alertdialog"]')) return;
      const typing = Boolean(t.closest("input,textarea,select,[contenteditable]"));
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "p") {
        e.preventDefault();
        handlers.current.park();
      } else if (e.key === "F9" || ((e.ctrlKey || e.metaKey) && e.key === "Enter")) {
        e.preventDefault();
        handlers.current.openPay();
      } else if (!typing && e.key === "/") {
        e.preventDefault();
        scanRef.current?.focus();
        scanRef.current?.select();
      } else if (!typing && e.key === "?") {
        e.preventDefault();
        handlers.current.help();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  /* ------------------------------- clock -------------------------------- */

  const [clock, setClock] = React.useState<string | null>(null);
  React.useEffect(() => {
    const tick = () => setClock(new Date().toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }));
    const first = window.setTimeout(tick, 0);
    const t = window.setInterval(tick, 15000);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(t);
    };
  }, [locale]);

  const ticketProps = {
    computed,
    ticketNumber: receipt?.sale.number ?? nextNumber,
    money,
    discount,
    ageVerified,
    flash,
    parked,
    currencySymbol,
    onQty: setQty,
    onLineDiscount: (productId: string, d: Discount | undefined) => setLines((ls) => ls.map((l) => (l.productId === productId ? { ...l, discount: d } : l))),
    onRemove: removeLine,
    onDiscount: setDiscount,
    onPark: park,
    onResume: resume,
    onClear: () => {
      const prev = { lines, discount };
      resetTicket();
      if (prev.lines.length) showToast("Ticket cleared", () => {
        setLines(prev.lines);
        setDiscount(prev.discount);
      });
    },
  };

  const pill = <OnlinePill online={online} canToggle={canToggle} onToggle={toggleOnline} />;

  return (
    <MotionConfig reducedMotion="user">
      <div
        ref={rootRef}
        className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}
      >
        <div inert={anyModal ? true : undefined} className="flex min-w-0 flex-1">
          {/* Rail (desktop) */}
          <aside aria-label="Till" className="hidden w-[232px] shrink-0 flex-col border-r bg-muted/40 p-4 lg:flex dark:bg-muted/20">
            <Brand storeName={storeName} tillName={tillName} />
            <div className="mt-4">{pill}</div>
            <div className="mt-4 flex items-center gap-2.5 rounded-2xl border bg-card p-2.5">
              <Initials name={cashier.name} />
              <div className="min-w-0">
                <p className="truncate text-[13px] font-medium">{cashier.name}</p>
                <p className="text-[11px] text-muted-foreground">Cashier · shift from 09:00</p>
              </div>
            </div>
            <div className="mt-3 rounded-2xl border bg-card p-3">
              <p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Shift total</p>
              <RollingText value={money(shiftTotal)} className="mt-0.5 text-xl font-bold tracking-tight" />
              <p className="text-[11px] text-muted-foreground">{sales.length} sales today</p>
              <ShiftBars sales={sales} />
            </div>
            <nav aria-label="Till tools" className="mt-3 space-y-1">
              <RailButton onClick={() => setQueueOpen(true)} icon={<CloudUpload className="size-4" aria-hidden />} label="Sync queue & history">
                {pendingCount > 0 && <Badge tone="amber">Queued ({pendingCount})</Badge>}
              </RailButton>
              <RailButton onClick={() => setHelpOpen(true)} icon={<Keyboard className="size-4" aria-hidden />} label="Keyboard shortcuts">
                <Kbd>?</Kbd>
              </RailButton>
            </nav>
            <p className="mt-auto flex items-center justify-between text-[11px] text-muted-foreground">
              <span>{storeName} · {tillName}</span>
              <span className="tabular-nums">{clock ?? ""}</span>
            </p>
          </aside>

          <div className="flex min-w-0 flex-1 flex-col">
            {/* Top bar (tablet / mobile) */}
            <header className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:px-4 lg:hidden">
              <Brand storeName={storeName} tillName={tillName} compact />
              <div className="ml-auto flex items-center gap-1.5">
                {pill}
                <button
                  type="button"
                  onClick={() => setQueueOpen(true)}
                  aria-label={pendingCount ? `Sync queue, ${pendingCount} queued` : "Sync queue and history"}
                  className={cn("relative grid size-10 place-items-center rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground", focusRing)}
                >
                  <ReceiptText className="size-4.5" />
                  {pendingCount > 0 && (
                    <span className="absolute right-1 top-1 grid h-4 min-w-4 place-items-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white">{pendingCount}</span>
                  )}
                </button>
                <button
                  type="button"
                  onClick={() => setHelpOpen(true)}
                  aria-label="Keyboard shortcuts"
                  className={cn("hidden size-10 place-items-center rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground sm:grid", focusRing)}
                >
                  <Keyboard className="size-4.5" />
                </button>
              </div>
            </header>

            <div className="flex min-h-0 flex-1">
              <main aria-label="Products" className="flex min-w-0 flex-1 flex-col">
                <ProductGrid products={stocked} categories={categories} qtyById={qtyById} money={money} onAdd={addProduct} scanRef={scanRef} />
              </main>
              <section aria-label="Current ticket" className="hidden w-[40%] shrink-0 flex-col border-l bg-card md:flex lg:w-[360px] xl:w-[380px]">
                <TicketPane {...ticketProps} payRef={payRef} onPay={openPay} />
              </section>
            </div>

            {/* Mobile ticket bar */}
            <div className="shrink-0 border-t bg-card/95 px-3 pb-3 pt-2.5 backdrop-blur md:hidden">
              <div className="flex items-center gap-2">
                <button
                  type="button"
                  onClick={() => setTicketOpen(true)}
                  className={cn("flex h-14 min-w-0 flex-1 items-center gap-3 rounded-2xl border bg-background px-3 text-left", focusRing)}
                  aria-label={`View ticket, ${computed.items} items, ${money(computed.total)}`}
                >
                  <span className="relative grid size-9 shrink-0 place-items-center rounded-xl bg-muted">
                    <ShoppingBasket className="size-4.5" aria-hidden />
                    <AnimatePresence>
                      {computed.items > 0 && (
                        <motion.span
                          key={computed.items}
                          initial={{ scale: 0.4 }}
                          animate={{ scale: 1 }}
                          className="absolute -right-1 -top-1 grid h-5 min-w-5 place-items-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground"
                        >
                          {computed.items}
                        </motion.span>
                      )}
                    </AnimatePresence>
                  </span>
                  <span className="min-w-0">
                    <span className="block text-[11px] text-muted-foreground">
                      {computed.items} {computed.items === 1 ? "item" : "items"} · View ticket
                    </span>
                    <RollingText value={money(computed.total)} className="text-base font-bold" />
                  </span>
                </button>
                <button
                  ref={mobilePayRef}
                  type="button"
                  onClick={openPay}
                  disabled={!lines.length}
                  className={cn("h-14 shrink-0 rounded-2xl bg-primary px-6 text-base font-semibold text-primary-foreground shadow-lg shadow-primary/20 disabled:opacity-40 disabled:shadow-none", focusRing)}
                >
                  Pay
                </button>
              </div>
            </div>
          </div>
        </div>

        {/* Toast */}
        <div className="pointer-events-none absolute inset-x-0 bottom-24 z-[60] flex justify-center px-4 md:bottom-5">
          <AnimatePresence>
            {toast && (
              <motion.div
                key={toast.id}
                role="status"
                initial={{ opacity: 0, y: 12, scale: 0.97 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, y: 8, scale: 0.97 }}
                className="pointer-events-auto flex items-center gap-3 rounded-2xl bg-foreground py-2 pl-4 pr-2 text-sm text-background shadow-xl"
              >
                <span>{toast.text}</span>
                {toast.undo && (
                  <button
                    type="button"
                    onClick={() => {
                      toast.undo?.();
                      setToast(null);
                    }}
                    className="h-8 rounded-lg px-2.5 text-xs font-semibold text-background underline-offset-2 hover:bg-background/10 hover:underline focus-visible:outline-2 focus-visible:outline-background"
                  >
                    Undo
                  </button>
                )}
                <button type="button" onClick={() => setToast(null)} aria-label="Dismiss" className="grid size-8 place-items-center rounded-lg hover:bg-background/10 focus-visible:outline-2 focus-visible:outline-background">
                  <X className="size-3.5" />
                </button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

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

        {/* Mobile ticket sheet */}
        <Modal open={ticketOpen} onClose={() => setTicketOpen(false)} labelledBy="pos-sheet-title" placement="bottom" className="h-[680px]">
          <div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-muted-foreground/25" aria-hidden />
          <TicketPane
            {...ticketProps}
            titleId="pos-sheet-title"
            onClose={() => setTicketOpen(false)}
            onPay={() => {
              setTicketOpen(false);
              window.setTimeout(openPayFromSheet, 180);
            }}
          />
        </Modal>

        <PaymentSheet
          open={payOpen}
          total={computed.total}
          online={online}
          money={money}
          onClose={() => setPayOpen(false)}
          onComplete={completeSale}
          onAnnounce={setAnnounce}
          returnFocus={() => (payRef.current?.offsetParent ? payRef.current : mobilePayRef.current)}
        />

        <Receipt
          sale={receipt?.sale ?? null}
          computed={receipt?.computed ?? null}
          money={money}
          locale={locale}
          storeName={storeName}
          storeAddress={storeAddress}
          tillName={tillName}
          cashier={cashier.name}
          onNewSale={newSale}
        />

        <OfflineQueue
          open={queueOpen}
          onClose={() => setQueueOpen(false)}
          sales={sales}
          offlineIds={offlineIds}
          online={online}
          canToggle={canToggle}
          onToggleOnline={toggleOnline}
          money={money}
          locale={locale}
        />

        <AgeCheck product={ageProduct} label={ageCheckLabel ?? `Confirm customer is ${minAge}+`} minAge={minAge} onResolve={resolveAge} money={money} />

        <Modal open={helpOpen} onClose={() => setHelpOpen(false)} labelledBy="pos-help-title">
          <div className="p-5">
            <div className="flex items-center">
              <h2 id="pos-help-title" className="text-sm font-semibold">
                Keyboard shortcuts
              </h2>
              <button type="button" onClick={() => setHelpOpen(false)} aria-label="Close shortcuts" className={cn("ml-auto grid size-9 place-items-center rounded-lg text-muted-foreground hover:bg-muted", focusRing)}>
                <X className="size-4" />
              </button>
            </div>
            <dl className="mt-3 divide-y text-sm">
              {[
                ["/", "Focus scanner / search"],
                ["Enter", "Add scanned code · confirm"],
                ["F9", "Pay (also Ctrl + Enter)"],
                ["K · C · S", "Card · Cash · Split in payment"],
                ["0–9 , ⌫", "Cash keypad"],
                ["Ctrl + P", "Park ticket"],
                ["Esc", "Close sheet or dialog"],
                ["?", "This list"],
              ].map(([k, v]) => (
                <div key={k} className="flex items-center justify-between py-2">
                  <dt className="text-muted-foreground">{v}</dt>
                  <dd>
                    <Kbd className="h-6 px-1.5 text-[11px]">{k}</Kbd>
                  </dd>
                </div>
              ))}
            </dl>
          </div>
        </Modal>
      </div>
    </MotionConfig>
  );

  function openPayFromSheet() {
    if (!lines.length) return;
    if (computed.hasRestricted && !ageVerified) {
      setPendingAge(computed.rows.find((r) => r.product.ageRestricted)?.product.id ?? null);
      return;
    }
    setPayOpen(true);
  }
}

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

function Brand({ storeName, tillName, compact }: { storeName: string; tillName: string; compact?: boolean }) {
  return (
    <div className="flex min-w-0 items-center gap-2.5">
      <span aria-hidden className="grid size-9 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-rose-600 to-fuchsia-700 text-white shadow-md shadow-rose-600/25">
        <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="1.8">
          <circle cx="9" cy="14" r="2.6" />
          <circle cx="15" cy="14" r="2.6" />
          <circle cx="12" cy="18.3" r="2.6" />
          <circle cx="12" cy="9.8" r="2.6" />
          <path d="M12 7.2V4M12 5c1.8-1.4 3.8-1.4 5 0" />
        </svg>
      </span>
      <div className="min-w-0 leading-tight">
        <p className={cn("truncate font-semibold tracking-tight", compact ? "text-sm" : "text-[15px]")}>{storeName}</p>
        <p className="truncate text-[11px] text-muted-foreground">{tillName}</p>
      </div>
    </div>
  );
}

function OnlinePill({ online, canToggle, onToggle }: { online: boolean; canToggle: boolean; onToggle: () => void }) {
  const content = (
    <>
      <span className="relative grid size-2 place-items-center">
        {online && <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60 motion-reduce:hidden" />}
        <span className={cn("relative size-2 rounded-full transition-colors duration-500", online ? "bg-emerald-500" : "bg-amber-500")} />
      </span>
      {online ? <Wifi className="size-3.5" aria-hidden /> : <WifiOff className="size-3.5" aria-hidden />}
      <span>{online ? "Online" : "Offline"}</span>
    </>
  );
  const cls = cn(
    "inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors duration-500",
    online
      ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
      : "border-amber-500/40 bg-amber-500/15 text-amber-800 dark:text-amber-300",
  );
  if (!canToggle) return <span className={cls}>{content}</span>;
  return (
    <button type="button" role="switch" aria-checked={online} aria-label="Connection (demo toggle)" title="Toggle connection (demo)" onClick={onToggle} className={cn(cls, focusRing)}>
      {content}
    </button>
  );
}

function RailButton({ onClick, icon, label, children }: { onClick: () => void; icon: React.ReactNode; label: string; children?: React.ReactNode }) {
  return (
    <button type="button" onClick={onClick} className={cn("flex h-10 w-full items-center gap-2.5 rounded-xl px-2.5 text-left text-[13px] font-medium text-muted-foreground transition hover:bg-card hover:text-foreground", focusRing)}>
      {icon}
      <span className="min-w-0 flex-1 truncate">{label}</span>
      {children}
    </button>
  );
}

function Badge({ children, tone }: { children: React.ReactNode; tone: "amber" }) {
  return <span className={cn("shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold tabular-nums", tone === "amber" && "bg-amber-500/15 text-amber-700 dark:text-amber-300")}>{children}</span>;
}

function ShiftBars({ sales }: { sales: Sale[] }) {
  const recent = sales.slice(-14);
  const max = Math.max(1, ...recent.map((s) => s.total));
  return (
    <div className="mt-2.5 flex h-8 items-end gap-1" aria-hidden>
      {recent.map((s) => (
        <motion.span
          key={s.id}
          initial={{ scaleY: 0 }}
          animate={{ scaleY: 1 }}
          className={cn("min-w-1 flex-1 origin-bottom rounded-sm", s.status === "synced" ? "bg-primary/70" : "bg-amber-500")}
          style={{ height: `${Math.max(12, (s.total / max) * 100)}%` }}
        />
      ))}
    </div>
  );
}

function AgeCheck({
  product,
  label,
  minAge,
  onResolve,
  money,
}: {
  product: Product | null;
  label: string;
  minAge: number;
  onResolve: (confirmed: boolean) => void;
  money: (m: number) => string;
}) {
  const confirmRef = React.useRef<HTMLButtonElement>(null);
  return (
    <Modal open={Boolean(product)} onClose={() => onResolve(false)} labelledBy="pos-age-title" initialFocus={confirmRef} role="alertdialog">
      {product && (
        <div className="p-6 text-center">
          <motion.div
            initial={{ rotate: -8, scale: 0.8 }}
            animate={{ rotate: 0, scale: 1 }}
            transition={{ type: "spring", stiffness: 300, damping: 16 }}
            className="mx-auto grid size-16 place-items-center rounded-2xl bg-amber-500/15 text-amber-600 dark:text-amber-400"
          >
            <IdCard className="size-8" aria-hidden />
          </motion.div>
          <p className="mt-4 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-300">Age check · {minAge}+</p>
          <h2 id="pos-age-title" className="mt-1 text-lg font-semibold tracking-tight">
            {label}
          </h2>
          <p className="mt-1.5 text-sm text-muted-foreground">
            <span className="font-medium text-foreground">{product.name}</span> ({money(Math.round(product.price * 100))}) is age-restricted. Check a photo ID — you only need to do this once per ticket.
          </p>
          <div className="mt-5 grid gap-2 sm:grid-cols-2">
            <button type="button" onClick={() => onResolve(false)} className={cn("h-12 rounded-2xl border text-sm font-semibold hover:bg-muted", focusRing)}>
              Remove item
            </button>
            <button ref={confirmRef} type="button" onClick={() => onResolve(true)} className={cn("h-12 rounded-2xl bg-primary text-sm font-semibold text-primary-foreground hover:bg-primary/90", focusRing)}>
              Confirmed (ID seen)
            </button>
          </div>
        </div>
      )}
    </Modal>
  );
}

export default PosRegisterApp;

More in E-commerce

View all →