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