Files
meezi/web/dashboard/src/components/pos2/pos2-screen.tsx
T
soroush.asadi 72abf05a5f
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 52s
CI/CD / Deploy · all services (push) Successful in 3m20s
fix(dashboard): review fixes — error toasts, dedupe socket, POS guards
- Global MutationCache.onError safety net so mutations without their own onError
  no longer fail silently (skips ones that handle errors → no double toast).
- Notifications feed no longer opens its own SignalR connection; it reuses the
  one in useOrderAlerts (was double sockets + double cache churn per session).
- "Send test notification" now works on the settings page (force flag bypasses
  the tab-visible guard) instead of silently doing nothing.
- POS: re-entry guard on payment confirm (no duplicate payment on double-tap);
  notes on already-sent lines are read-only (a note-only edit was silently lost);
  ORDER_ALREADY_CLOSED surfaced with a clear Persian message.
- Reservation Confirm/Cancel/Complete buttons disabled while pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:54:02 +03:30

1118 lines
53 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ─────────────────────────────────────────────────────────────────────────────
// POS v2 — WIRED to live data. Reuses the existing data layer + cart store +
// submit/payment endpoints (shares React Query cache with the classic POS).
// Flow: table board → order screen → pay sheet → back to board.
// Mounted at /[locale]/pos (and /pos2). Design mirrors pos2-prototype.tsx.
// ─────────────────────────────────────────────────────────────────────────────
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2,
BadgePercent, Sparkles, Home, StickyNote,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify";
import { useRouter } from "@/i18n/routing";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
import { useCartStore } from "@/lib/stores/cart.store";
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos2/submit-order";
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { printReceipt } from "@/lib/api/print";
import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker";
import { Can } from "@/components/auth/can";
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
const fmt = (n: number) => Math.round(n).toLocaleString("fa-IR");
const TAX = 0.09;
const POINT_VALUE = 100; // 1 loyalty point = 100 toman (matches classic POS)
type Method = "Cash" | "Card" | "Credit";
type Payment = { method: Method; amount: number };
const errMsg = (e: unknown, fb: string) => (e instanceof ApiClientError ? e.message || fb : fb);
const COUPON_FA: Record<string, string> = {
COUPON_NOT_FOUND: "کد تخفیف نامعتبر است",
COUPON_INACTIVE: "کد تخفیف غیرفعال است",
COUPON_EXPIRED: "کد تخفیف منقضی شده است",
COUPON_NOT_STARTED: "کد تخفیف هنوز فعال نشده است",
COUPON_LIMIT_REACHED: "سقف استفاده از این کد پر شده است",
COUPON_MIN_ORDER: "حداقل مبلغ سفارش رعایت نشده است",
CART_EMPTY: "سبد خالی است",
COUPON_REQUIRED: "کد تخفیف را وارد کنید",
COUPON_NO_DISCOUNT: "کد تخفیف نامعتبر است",
};
const couponErr = (e: unknown) =>
e instanceof ApiClientError ? COUPON_FA[e.code] ?? "کد تخفیف نامعتبر است" : "کد تخفیف نامعتبر است";
const POS_DEVICE_FA: Record<string, string> = {
posDeviceNotConfigured: "دستگاه کارت‌خوان تنظیم نشده است",
posDeviceConnectionFailed: "اتصال به کارت‌خوان ناموفق بود",
posDeviceTimeout: "زمان پاسخ کارت‌خوان به پایان رسید",
posDeviceRejected: "تراکنش توسط کارت‌خوان رد شد",
posDeviceError: "خطای کارت‌خوان",
};
const posDeviceMsg = (e: unknown) => posDeviceErrorMessage(e, (k) => POS_DEVICE_FA[k] ?? "خطای کارت‌خوان");
export function Pos2Screen() {
const router = useRouter();
const queryClient = useQueryClient();
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useBranchStore((s) => s.branchId);
const setBranchId = useBranchStore((s) => s.setBranchId);
// Resolve a VALID branch (auto-pick the first) exactly like the classic POS —
// the menu/tables are branch-scoped, so a null or stale stored branchId would
// otherwise load an empty menu. v2 has no branch picker, so it must self-heal.
//
// IMPORTANT: orderBranchId returns `undefined` while branches are still loading.
// usePos2Menu treats `undefined` as "not yet determined" and pauses the query so
// we never fire getBranchMenu(cafeId, null) which returns an empty array.
const { data: branches = [], isFetched: branchesFetched } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<{ id: string; name: string }[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (!branchesFetched || branches.length === 0) return;
const valid = branchId && branches.some((b) => b.id === branchId);
if (!valid) setBranchId(branches[0]!.id);
}, [branchesFetched, branches, branchId, setBranchId]);
const orderBranchId = useMemo<string | null | undefined>(() => {
if (!branchesFetched) return undefined; // still loading → pause the menu query
if (branchId && branches.some((b) => b.id === branchId)) return branchId;
return branches[0]?.id ?? null; // null = no branches → café-wide fallback
}, [branchesFetched, branchId, branches]);
const { data: categories } = usePos2Categories(cafeId);
const { data: menu, isLoading: menuLoading, isError: menuError, refetch: refetchMenu } = usePos2Menu(cafeId, orderBranchId);
const { data: tables, isLoading: tablesLoading, refetch: refetchTables } = usePos2Tables(cafeId, orderBranchId);
const menuById = useMenuById(menu);
// cart store slices
const items = useCartStore((s) => s.items);
const syncedQty = useCartStore((s) => s.syncedQtyByMenuId);
const addItem = useCartStore((s) => s.addItem);
const updateQty = useCartStore((s) => s.updateQty);
const removeItem = useCartStore((s) => s.removeItem);
const setNotes = useCartStore((s) => s.setNotes);
const setTableId = useCartStore((s) => s.setTableId);
const setOrderType = useCartStore((s) => s.setOrderType);
const hydrateFromOrder = useCartStore((s) => s.hydrateFromOrder);
const clearSession = useCartStore((s) => s.clearSession);
const activeOrderNo = useCartStore((s) => s.activeOrderDisplayNumber);
const activeOrderId = useCartStore((s) => s.activeOrderId);
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
// local view state
const [view, setView] = useState<"board" | "order">("board");
const [activeTable, setActiveTable] = useState<TableBoardItem | null>(null);
const [takeaway, setTakeaway] = useState(false);
const [cat, setCat] = useState("all");
const [q, setQ] = useState("");
const [cartOpen, setCartOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [payTarget, setPayTarget] = useState<Order | null>(null);
const [payLoyalty, setPayLoyalty] = useState(0);
// Order just paid — kept after the cart is cleared so the receipt stays printable.
const [paidOrderId, setPaidOrderId] = useState<string | null>(null);
const payingRef = useRef(false); // re-entry guard for the payment confirm
const [online, setOnline] = useState(true);
useEffect(() => {
const u = () => setOnline(typeof navigator === "undefined" ? true : navigator.onLine);
u();
window.addEventListener("online", u);
window.addEventListener("offline", u);
return () => { window.removeEventListener("online", u); window.removeEventListener("offline", u); };
}, []);
const live = items.filter((l) => !l.isVoided);
const subtotal = live.reduce((s, l) => s + l.menuItem.price * l.quantity, 0);
const discount = appliedCoupon?.discountAmount ?? 0;
const taxable = Math.max(0, subtotal - discount);
const tax = Math.round(taxable * TAX);
const total = taxable + tax;
const count = live.reduce((s, l) => s + l.quantity, 0);
const pendingCount = items.reduce(
(n, l) => n + Math.max(0, l.quantity - (syncedQty[l.menuItem.id] ?? 0)),
0,
);
const visibleItems = useMemo(() => {
const list = (menu ?? []).filter((i) => i.isAvailable !== false);
return list.filter(
(i) =>
(cat === "all" || i.categoryId === cat) &&
(q === "" || i.name.includes(q) || (i.nameEn ?? "").toLowerCase().includes(q.toLowerCase())),
);
}, [menu, cat, q]);
const catChips = useMemo(
() => [{ id: "all", name: "همه" }, ...(categories ?? []).map((c) => ({ id: c.id, name: c.name }))],
[categories],
);
// ── navigation ───────────────────────────────────────────────────────────
const openFreeTable = (t: TableBoardItem) => {
clearSession();
setTableId(t.id);
setOrderType("table");
setActiveTable(t); setTakeaway(false); setCat("all"); setQ(""); setCartOpen(false);
setView("order");
};
const openBusyTable = async (t: TableBoardItem) => {
const oid = t.currentOrder?.orderId;
if (!oid) return openFreeTable(t);
setBusy(true);
try {
const order = await apiGet<Order>(`/api/cafes/${cafeId}/orders/${oid}`);
hydrateFromOrder(order, menuById);
setOrderType("table");
setActiveTable(t); setTakeaway(false); setCat("all"); setQ(""); setCartOpen(false);
setView("order");
} catch (e) {
notify.error(errMsg(e, "بارگذاری سفارش میز ناموفق بود"));
} finally {
setBusy(false);
}
};
const openTakeaway = () => {
clearSession();
setOrderType("takeaway");
setActiveTable(null); setTakeaway(true); setCat("all"); setQ(""); setCartOpen(false);
setView("order");
};
const backToBoard = () => {
clearSession();
setActiveTable(null); setTakeaway(false); setPayTarget(null); setCartOpen(false);
setView("board");
refetchTables();
};
// ── actions ──────────────────────────────────────────────────────────────
const submitPending = async (): Promise<Order | null> => {
const cart = useCartStore.getState();
if (cart.getPendingLines().length === 0) return null;
const order = await submitOrderToApi({
cafeId: cafeId as string,
orderBranchId: orderBranchId ?? undefined,
cart,
reservationId: null,
cartItems: cart.items,
});
hydrateFromOrder(order, menuById);
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
return order;
};
const send = async () => {
if (pendingCount === 0) { notify.error("آیتمی برای ارسال نیست"); return; }
setBusy(true);
try {
const order = await submitPending();
if (order) {
notify.success(
isLocalOrder(order.id)
? "سفارش آفلاین ذخیره شد و هنگام اتصال ارسال می‌شود"
: "سفارش به آشپزخانه ارسال شد",
);
}
} catch (e) {
notify.error(errMsg(e, "ارسال سفارش ناموفق بود"));
} finally {
setBusy(false);
}
};
const openPay = async () => {
if (count === 0) { notify.error("سبد خالی است"); return; }
setBusy(true);
try {
let order = await submitPending();
if (!order) {
const cart = useCartStore.getState();
if (cart.activeOrderId && !isLocalOrder(cart.activeOrderId)) {
order = await apiGet<Order>(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}`);
}
}
if (!order) {
const cart = useCartStore.getState();
order = {
id: cart.activeOrderId ?? "local_pending",
cafeId: cafeId as string,
orderType: "DineIn", status: "Open",
subtotal, taxTotal: tax, discountAmount: discount, total,
paidAmount: 0, createdAt: new Date().toISOString(), displayNumber: 0,
items: [], payments: [],
};
}
// loyalty points available on the attached customer (1 pt = 100 toman)
const customerId = useCartStore.getState().customerId;
let pts = 0;
if (customerId && !isLocalOrder(order.id)) {
try {
const c = await apiGet<Customer>(`/api/cafes/${cafeId}/customers/${customerId}`);
pts = c.loyaltyPoints ?? 0;
} catch { pts = 0; }
}
setPayLoyalty(pts);
setPayTarget(order);
} catch (e) {
notify.error(errMsg(e, "آماده‌سازی پرداخت ناموفق بود"));
} finally {
setBusy(false);
}
};
const confirmPay = async (payments: Payment[], loyaltyRedeem: number) => {
if (!payTarget || payingRef.current) return; // guard against a double-tap
payingRef.current = true;
setBusy(true);
try {
const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0);
const payBranchId = payTarget.branchId ?? orderBranchId ?? undefined;
if (cardTotal > 0 && payBranchId) {
// push the card amount to the configured terminal (no-op/skip if none)
await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal);
}
await apiPost(`/api/cafes/${cafeId}/orders/${payTarget.id}/payments`, {
payments,
loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined,
});
const paid = payments.reduce((s, p) => s + p.amount, 0);
const justPaidOrderId = payTarget.id;
notify.success(`پرداخت ${fmt(paid)} تومان ثبت شد`);
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
// Clear the cart + close the pay sheet, but STAY on the order view so the
// payment-success sheet (which lives in this view) renders and the cashier
// can print the receipt. Going back to the board happens on dismiss.
clearSession();
setPayTarget(null);
setCartOpen(false);
if (!isLocalOrder(justPaidOrderId)) setPaidOrderId(justPaidOrderId);
else backToBoard();
} catch (e) {
if (e instanceof ApiClientError && e.code.startsWith("POS_DEVICE")) {
notify.error(posDeviceMsg(e));
} else if (e instanceof ApiClientError && e.code === "NO_OPEN_SHIFT") {
notify.error("برای پرداخت باید شیفت باز باشد");
} else if (e instanceof ApiClientError && e.code === "ORDER_ALREADY_CLOSED") {
notify.error("این سفارش قبلاً تسویه شده است");
} else {
notify.error(errMsg(e, "ثبت پرداخت ناموفق بود"));
}
} finally {
payingRef.current = false;
setBusy(false);
}
};
// Print (or reprint) the customer receipt for a saved server order.
const printReceiptById = async (orderId: string) => {
try {
await printReceipt(cafeId as string, orderId);
notify.success("فاکتور برای چاپ ارسال شد");
} catch (e) {
const code = e instanceof ApiClientError ? e.code : "";
notify.error(
code === "PRINTER_NOT_CONFIGURED" || code === "KITCHEN_PRINTER_NOT_CONFIGURED"
? "پرینتر فاکتور تنظیم نشده است"
: code === "PRINTER_CONNECTION_FAILED"
? "اتصال به پرینتر برقرار نشد"
: "چاپ فاکتور ناموفق بود",
);
}
};
const printActiveReceipt = async () => {
if (!activeOrderId || isLocalOrder(activeOrderId)) {
notify.error("ابتدا سفارش را ثبت کنید");
return;
}
await printReceiptById(activeOrderId);
};
// ── guards ───────────────────────────────────────────────────────────────
if (!cafeId) {
return (
<div className="flex h-svh items-center justify-center text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
const offlineBadge = !online && (
<span className="flex items-center gap-1.5 rounded-lg bg-amber-100 px-2.5 py-1.5 text-xs font-medium text-amber-800">
<WifiOff className="size-4" /> آفلاین
</span>
);
const ticketProps = {
cafeId,
// mark fully-sent lines so their note becomes read-only (a note-only change on
// an already-sent line would otherwise be silently dropped on the next send).
lines: live.map((l) => ({ ...l, synced: (syncedQty[l.menuItem.id] ?? 0) >= l.quantity })),
subtotal, discount, tax, total, count, pendingCount,
onBump: (id: string, d: number) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); },
onRemove: removeItem, onSend: send, onPay: openPay, onSplit: openPay,
onNote: (id: string, notes: string) => setNotes(id, notes),
canPrint: !!activeOrderId && !isLocalOrder(activeOrderId),
onPrintReceipt: printActiveReceipt,
};
// ── TABLE BOARD ────────────────────────────────────────────────────────────
if (view === "board") {
const list = tables ?? [];
const occupied = list.filter((t) => t.status === "Busy").length;
return (
<div dir="rtl" className="flex h-svh min-h-0 flex-col bg-background text-foreground">
{busy && <BusyOverlay />}
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-4 py-3">
{/* Dashboard exit — lets user navigate back without the sidebar */}
<button
type="button"
onClick={() => router.push("/")}
className="flex min-h-[40px] cursor-pointer items-center gap-1.5 rounded-xl px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
title="بازگشت به داشبورد"
>
<Home className="size-4" />
<span className="hidden sm:inline">داشبورد</span>
</button>
<div className="h-5 w-px bg-border" />
<div className="flex items-center gap-2 text-primary">
<LayoutGrid className="size-6" />
<span className="text-lg font-bold">میزها</span>
</div>
<span className="hidden rounded-lg bg-muted px-2.5 py-1 text-sm text-muted-foreground sm:inline">
{fmt(occupied)} فعال · {fmt(Math.max(0, list.length - occupied))} خالی
</span>
<div className="flex-1" />
{offlineBadge}
<button
type="button"
onClick={openTakeaway}
className="flex min-h-[44px] cursor-pointer items-center gap-2 rounded-xl bg-primary px-4 font-bold text-primary-foreground transition-colors hover:bg-primary/90 active:scale-[0.98]"
>
<ShoppingBag className="size-5" /> بیرونبر
</button>
</header>
{tablesLoading ? (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
) : list.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center text-muted-foreground">
<Armchair className="size-12 opacity-40" />
<p>هنوز میزی تعریف نشده است.</p>
<button type="button" onClick={openTakeaway} className="rounded-xl bg-primary px-5 py-2.5 font-bold text-primary-foreground">
شروع سفارش بیرونبر
</button>
</div>
) : (
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-3 overflow-y-auto p-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{list.map((t) => {
const busyT = t.status === "Busy";
const reserved = t.status === "Reserved";
const cleaning = t.status === "Cleaning" || t.isCleaning;
const open = () => (busyT ? openBusyTable(t) : openFreeTable(t));
return (
<button
key={t.id}
type="button"
onClick={open}
className={cn(
"flex min-h-[124px] cursor-pointer flex-col justify-between rounded-2xl border-2 p-3 text-start transition-all hover:shadow-md active:scale-[0.97]",
busyT ? "border-primary/40 bg-primary/5"
: reserved ? "border-amber-300 bg-amber-50"
: cleaning ? "border-border bg-muted/50"
: "border-border bg-card hover:border-primary/40",
)}
>
<div className="flex items-center justify-between">
<span className="text-lg font-extrabold">میز {t.number}</span>
<Armchair className={cn("size-5", busyT ? "text-primary" : "text-muted-foreground/50")} />
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="size-3.5" /> ظرفیت {fmt(t.capacity)}
{t.sectionName ? <span className="truncate">· {t.sectionName}</span> : null}
</div>
{busyT ? (
<div className="flex items-center justify-between">
{t.currentOrder?.source === "GuestQr" ? (
<span className="inline-flex items-center gap-1 rounded-md bg-primary/15 px-1.5 py-0.5 text-[11px] font-bold text-primary">
<ReceiptText className="size-3" /> مهمان
</span>
) : <span className="text-xs text-muted-foreground">باز</span>}
<span className="text-sm font-extrabold text-primary">{fmt(t.currentOrder?.total ?? 0)}</span>
</div>
) : (
<span className={cn("text-sm font-medium", reserved ? "text-amber-700" : "text-muted-foreground")}>
{reserved ? "رزرو" : cleaning ? "در حال تمیزکاری" : "خالی"}
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}
// ── ORDER SCREEN ───────────────────────────────────────────────────────────
const title = takeaway ? "بیرون‌بر" : `میز ${activeTable?.number ?? ""}`;
return (
<div dir="rtl" className="flex h-svh min-h-0 flex-col bg-background text-foreground">
{busy && <BusyOverlay />}
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-3 py-2.5">
<button
type="button"
onClick={backToBoard}
className="flex min-h-[44px] cursor-pointer items-center gap-1.5 rounded-xl bg-muted px-3 font-medium text-muted-foreground transition-colors hover:bg-accent active:scale-95"
>
<ArrowRight className="size-5" /> <span className="hidden sm:inline">میزها</span>
</button>
<div className="flex items-center gap-2 rounded-xl bg-primary/10 px-3 py-1.5 text-primary">
{takeaway ? <ShoppingBag className="size-5" /> : <Coffee className="size-5" />}
<span className="font-bold">{title}</span>
</div>
{activeOrderNo ? (
<span className="hidden rounded-lg bg-muted px-2 py-1 text-xs font-bold text-muted-foreground sm:inline">
سفارش #{fmt(activeOrderNo)}
</span>
) : null}
<div className="relative mx-1 flex-1">
<Search className="pointer-events-none absolute end-3 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="جستجوی آیتم…"
className="h-11 w-full rounded-xl border border-border bg-background pe-10 ps-4 text-base outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
{offlineBadge}
</header>
<div className="flex min-h-0 flex-1">
{/* ── Left: vertical category sidebar (desktop) ── */}
<nav className="hidden w-[116px] shrink-0 flex-col gap-0.5 overflow-y-auto border-e border-border bg-card p-2 md:flex">
{catChips.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCat(c.id)}
className={cn(
"w-full cursor-pointer rounded-xl px-2 py-3 text-center text-xs font-semibold leading-tight transition-colors",
cat === c.id
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{c.name}
</button>
))}
</nav>
{/* ── Center: menu items ── */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* Horizontal chips — mobile only */}
<div className="flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5 md:hidden">
{catChips.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCat(c.id)}
className={cn(
"min-h-[44px] shrink-0 cursor-pointer rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
cat === c.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-accent",
)}
>
{c.name}
</button>
))}
</div>
{menuLoading ? (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
) : menuError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center text-muted-foreground">
<p>بارگذاری منو ناموفق بود.</p>
<button type="button" onClick={() => refetchMenu()} className="rounded-xl bg-primary px-5 py-2.5 font-bold text-primary-foreground">
تلاش دوباره
</button>
</div>
) : (
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-3 overflow-y-auto p-3 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4">
{visibleItems.map((it) => (
<button
key={it.id}
type="button"
onClick={() => addItem(it)}
className="flex min-h-[104px] cursor-pointer flex-col justify-between rounded-2xl border border-border bg-card p-3 text-start shadow-sm transition-all hover:border-primary hover:shadow-md active:scale-[0.97]"
>
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Coffee className="size-5" />
</div>
<div>
<p className="line-clamp-1 font-semibold">{it.name}</p>
<p className="mt-0.5 text-sm font-bold text-primary">{fmt(it.price)}</p>
</div>
</button>
))}
{visibleItems.length === 0 && (
<p className="col-span-full py-10 text-center text-muted-foreground">آیتمی یافت نشد</p>
)}
</div>
)}
</main>
{/* ── Right: order ticket (desktop) ── */}
<aside className="hidden w-[360px] shrink-0 border-s border-border bg-card lg:flex lg:flex-col">
<Ticket {...ticketProps} />
</aside>
</div>
{count > 0 && (
<button
type="button"
onClick={() => setCartOpen(true)}
className="flex shrink-0 cursor-pointer items-center justify-between gap-3 bg-primary px-4 py-3 text-primary-foreground lg:hidden"
>
<span className="flex items-center gap-2 font-bold">
<ShoppingCart className="size-5" />
<span className="flex size-6 items-center justify-center rounded-full bg-white/25 text-sm">{fmt(count)}</span>
مشاهده سفارش
</span>
<span className="text-lg font-extrabold">{fmt(total)} تومان</span>
</button>
)}
{cartOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setCartOpen(false)} />
<div className="absolute inset-y-0 end-0 flex w-full max-w-sm flex-col bg-card shadow-xl" dir="rtl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="font-bold">سفارش {title}</span>
<button type="button" onClick={() => setCartOpen(false)} className="rounded-lg p-2 hover:bg-accent">
<X className="size-5" />
</button>
</div>
<Ticket {...ticketProps} />
</div>
</div>
)}
{payTarget && (
<Pos2PaySheet
tableName={title}
amountDue={orderAmountDue(payTarget) || total}
loyaltyPoints={payLoyalty}
onClose={() => setPayTarget(null)}
onConfirm={confirmPay}
/>
)}
{paidOrderId && (
<div dir="rtl" className="fixed inset-0 z-[65] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => { setPaidOrderId(null); backToBoard(); }} />
<div className="relative w-full max-w-sm rounded-2xl bg-card p-6 text-center shadow-2xl">
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<Check className="size-7" />
</div>
<p className="text-lg font-bold">پرداخت با موفقیت ثبت شد</p>
<p className="mt-1 text-sm text-muted-foreground">فاکتور مشتری را میتوانید چاپ کنید</p>
<div className="mt-5 space-y-2">
<button
type="button"
onClick={() => void printReceiptById(paidOrderId)}
className="flex min-h-[52px] w-full items-center justify-center gap-2 rounded-xl bg-primary text-base font-bold text-primary-foreground transition-colors hover:bg-primary/90 active:scale-[0.99]"
>
<ReceiptText className="size-5" /> چاپ فاکتور
</button>
<button
type="button"
onClick={() => { setPaidOrderId(null); backToBoard(); }}
className="flex min-h-[48px] w-full items-center justify-center rounded-xl bg-muted text-sm font-medium text-foreground hover:bg-accent"
>
سفارش جدید
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ── Customer + coupon controls (self-contained: reads/writes the cart store) ──
function Pos2Extras({ cafeId }: { cafeId: string }) {
const guestName = useCartStore((s) => s.guestName);
const guestPhone = useCartStore((s) => s.guestPhone);
const customerId = useCartStore((s) => s.customerId);
const setGuestName = useCartStore((s) => s.setGuestName);
const setGuestPhone = useCartStore((s) => s.setGuestPhone);
const setCustomer = useCartStore((s) => s.setCustomer);
const clearCustomer = useCartStore((s) => s.clearCustomer);
const couponCode = useCartStore((s) => s.couponCode);
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
const setCouponCode = useCartStore((s) => s.setCouponCode);
const setAppliedCoupon = useCartStore((s) => s.setAppliedCoupon);
const clearCoupon = useCartStore((s) => s.clearCoupon);
const subtotalFn = useCartStore((s) => s.subtotal);
const [msg, setMsg] = useState<string | null>(null);
const apply = useMutation({
mutationFn: () =>
apiPost<{ couponId: string; code: string; discountAmount: number }>(
`/api/cafes/${cafeId}/coupons/validate`,
{ code: couponCode.trim(), subtotal: subtotalFn() },
),
onSuccess: (d) => {
setAppliedCoupon({ id: d.couponId, code: d.code, discountAmount: d.discountAmount });
setMsg(null);
},
onError: (e) => { setAppliedCoupon(null); setMsg(couponErr(e)); },
});
return (
<div className="shrink-0 space-y-2 border-t border-border px-3 py-2">
<PosCustomerPicker
cafeId={cafeId}
guestName={guestName}
guestPhone={guestPhone}
customerId={customerId}
onGuestNameChange={setGuestName}
onGuestPhoneChange={setGuestPhone}
onCustomerChange={setCustomer}
onClearCustomer={clearCustomer}
compact
/>
{appliedCoupon ? (
<div className="flex items-center justify-between gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-2.5 py-2 text-sm">
<span className="flex items-center gap-1.5 font-medium text-emerald-700">
<BadgePercent className="size-4" /> {appliedCoupon.code}
<span className="text-xs text-emerald-600">({fmt(appliedCoupon.discountAmount)})</span>
</span>
<button type="button" onClick={() => { clearCoupon(); setMsg(null); }} className="rounded-md p-1 text-emerald-700 hover:bg-emerald-100" aria-label="حذف کوپن">
<X className="size-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<BadgePercent className="pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={couponCode}
onChange={(e) => { setCouponCode(e.target.value); setMsg(null); }}
onKeyDown={(e) => { if (e.key === "Enter" && couponCode.trim() && !apply.isPending) apply.mutate(); }}
placeholder="کد تخفیف"
className="h-10 w-full rounded-lg border border-border bg-background pe-8 ps-3 text-sm outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
<button
type="button"
disabled={!couponCode.trim() || apply.isPending}
onClick={() => apply.mutate()}
className="flex h-10 min-w-[64px] items-center justify-center rounded-lg bg-muted px-3 text-sm font-medium hover:bg-accent disabled:opacity-40"
>
{apply.isPending ? <Loader2 className="size-4 animate-spin" /> : "اعمال"}
</button>
</div>
)}
{msg ? <p className="text-xs text-red-500">{msg}</p> : null}
</div>
);
}
// ── Order ticket ─────────────────────────────────────────────────────────────
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean };
function Ticket({
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
}: {
cafeId: string; lines: TicketLine[]; subtotal: number; discount: number; tax: number; total: number;
count: number; pendingCount: number;
onBump: (id: string, d: number) => void; onRemove: (id: string) => void;
onNote: (id: string, notes: string) => void;
onSend: () => void; onPay: () => void; onSplit: () => void;
canPrint: boolean; onPrintReceipt: () => void;
}) {
const [noteFor, setNoteFor] = useState<string | null>(null);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{lines.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<ShoppingCart className="size-10 opacity-40" />
<p>سبد خالی است</p>
<p className="text-xs">برای افزودن، روی آیتمها بزنید</p>
</div>
) : (
<ul className="space-y-2">
{lines.map((l) => (
<li key={l.menuItem.id} className="rounded-xl border border-border/70 p-2">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<p className="line-clamp-1 font-medium">{l.menuItem.name}</p>
<p className="text-xs text-muted-foreground">{fmt(l.menuItem.price)} تومان</p>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={() => onBump(l.menuItem.id, -1)} className="flex size-11 items-center justify-center rounded-lg bg-muted hover:bg-accent active:scale-95" aria-label="کم">
<Minus className="size-4" />
</button>
<span className="w-7 text-center font-bold">{fmt(l.quantity)}</span>
<button type="button" onClick={() => onBump(l.menuItem.id, 1)} className="flex size-11 items-center justify-center rounded-lg bg-primary/10 text-primary hover:bg-primary/20 active:scale-95" aria-label="زیاد">
<Plus className="size-4" />
</button>
</div>
<button type="button" onClick={() => onRemove(l.menuItem.id)} className="flex size-9 items-center justify-center rounded-lg text-red-500 hover:bg-red-50" aria-label="حذف">
<Trash2 className="size-4" />
</button>
</div>
{l.synced ? (
// Already sent to the kitchen — note is read-only (can't be changed now).
l.notes ? (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<StickyNote className="size-3.5 shrink-0" /> {l.notes}
</p>
) : null
) : noteFor === l.menuItem.id || l.notes ? (
<input
value={l.notes ?? ""}
onChange={(e) => onNote(l.menuItem.id, e.target.value)}
onBlur={() => { if (!l.notes) setNoteFor((cur) => (cur === l.menuItem.id ? null : cur)); }}
placeholder="یادداشت برای آشپزخانه (مثلاً بدون شکر)"
autoFocus={noteFor === l.menuItem.id}
maxLength={200}
dir="rtl"
className="mt-2 w-full rounded-lg border border-border/70 bg-background px-2.5 py-1.5 text-sm outline-none focus:border-primary"
/>
) : (
<button
type="button"
onClick={() => setNoteFor(l.menuItem.id)}
className="mt-1.5 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10"
>
<StickyNote className="size-3.5" /> افزودن یادداشت
</button>
)}
</li>
))}
</ul>
)}
</div>
<Pos2Extras cafeId={cafeId} />
<div className="space-y-1 border-t border-border px-4 py-3 text-sm">
<Row label="جمع" value={`${fmt(subtotal)} تومان`} />
{discount > 0 && <Row label="تخفیف" value={`${fmt(discount)} تومان`} accent />}
<Row label="مالیات ۹٪" value={`${fmt(tax)} تومان`} />
<div className="flex items-center justify-between pt-1 text-lg font-extrabold">
<span>مبلغ کل</span>
<span className="text-primary">{fmt(total)} تومان</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 border-t border-border p-3">
<button type="button" disabled={pendingCount === 0} onClick={onSend}
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl bg-primary text-base font-bold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 active:scale-[0.98]">
<Send className="size-5" /> ارسال{pendingCount > 0 ? ` (${fmt(pendingCount)})` : ""}
</button>
<Can permission="HandlePayments">
<button type="button" disabled={count === 0} onClick={onPay}
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
<CreditCard className="size-5" /> پرداخت
</button>
</Can>
<button type="button" disabled={!canPrint} onClick={onPrintReceipt}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-foreground hover:bg-accent disabled:opacity-40"
title="چاپ فاکتور مشتری">
<ReceiptText className="size-4" /> چاپ فاکتور
</button>
<Can permission="HandlePayments">
<button type="button" disabled={count === 0} onClick={onSplit}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<SplitSquareHorizontal className="size-4" /> تقسیم
</button>
</Can>
</div>
</div>
);
}
// ── Payment sheet (cash / card / split + numpad + loyalty) ───────────────────
function Pos2PaySheet({
tableName, amountDue, loyaltyPoints, onClose, onConfirm,
}: {
tableName: string; amountDue: number; loyaltyPoints: number;
onClose: () => void;
onConfirm: (payments: Payment[], loyaltyRedeem: number) => void;
}) {
const [method, setMethod] = useState<"cash" | "card" | "split">("card");
const [recv, setRecv] = useState("");
const [splitN, setSplitN] = useState(2);
const [splitMethods, setSplitMethods] = useState<Method[]>(["Card", "Card"]);
const [useLoyalty, setUseLoyalty] = useState(false);
useEffect(() => {
setSplitMethods((prev) => Array.from({ length: splitN }, (_, i) => prev[i] ?? "Cash"));
}, [splitN]);
const maxRedeem = Math.min(loyaltyPoints, Math.floor(amountDue / POINT_VALUE));
const redeem = useLoyalty ? maxRedeem : 0;
const due = Math.max(0, amountDue - redeem * POINT_VALUE);
const received = Number(recv || 0);
const change = received - due;
const press = (d: string) => setRecv((r) => (r + d).replace(/^0+(?=\d)/, "").slice(0, 12));
const backspace = () => setRecv((r) => r.slice(0, -1));
const roundUp = (step: number) => Math.ceil(due / step) * step;
const splitAmounts = useMemo(() => {
const base = Math.floor(due / splitN);
const arr = Array<number>(splitN).fill(base);
arr[splitN - 1] += due - base * splitN;
return arr;
}, [due, splitN]);
const canConfirm = method === "cash" ? received >= due || due === 0 : true;
const confirm = () => {
let payments: Payment[];
if (method === "cash") payments = [{ method: "Cash", amount: due }];
else if (method === "card") payments = [{ method: "Card", amount: due }];
else payments = splitAmounts.map((a, i) => ({ method: splitMethods[i] ?? "Cash", amount: a }));
onConfirm(payments.filter((p) => p.amount > 0), redeem);
};
// Card listed first (most common in Iran); Cash stays the pre-selected default.
const TABS = [
{ id: "card", name: "کارت", icon: CreditCard },
{ id: "cash", name: "نقدی", icon: Banknote },
{ id: "split", name: "تقسیم", icon: SplitSquareHorizontal },
] as const;
return (
<div dir="rtl" className="fixed inset-0 z-[60] flex items-stretch justify-center sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative flex max-h-svh w-full flex-col bg-card shadow-2xl sm:max-w-md sm:rounded-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<p className="text-xs text-muted-foreground">پرداخت {tableName}</p>
<p className="text-xl font-extrabold text-primary">
{fmt(due)} تومان
{redeem > 0 && <span className="ms-2 text-sm font-medium text-muted-foreground line-through">{fmt(amountDue)}</span>}
</p>
</div>
<button type="button" onClick={onClose} className="rounded-lg p-2 hover:bg-accent" aria-label="بستن">
<X className="size-5" />
</button>
</div>
<div className="grid grid-cols-3 gap-2 p-3">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setMethod(t.id)}
className={cn(
"flex min-h-[52px] cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border-2 text-sm font-bold transition-colors",
method === t.id ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-accent",
)}
>
<t.icon className="size-5" /> {t.name}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-2">
{loyaltyPoints > 0 && (
<button
type="button"
onClick={() => setUseLoyalty((v) => !v)}
className={cn(
"mb-3 flex w-full items-center justify-between rounded-xl border-2 px-3 py-2.5 text-start transition-colors",
useLoyalty ? "border-primary bg-primary/10" : "border-border hover:bg-accent",
)}
>
<span className="flex items-center gap-2 text-sm font-medium">
<Sparkles className={cn("size-4", useLoyalty ? "text-primary" : "text-muted-foreground")} />
امتیاز وفاداری ({fmt(loyaltyPoints)})
<span className="text-xs text-muted-foreground">هر امتیاز ۱۰۰ تومان</span>
</span>
<span className={cn("text-sm font-bold", useLoyalty ? "text-primary" : "text-muted-foreground")}>
{useLoyalty ? `${fmt(redeem * POINT_VALUE)}` : "استفاده"}
</span>
</button>
)}
{method === "cash" && (
<div className="space-y-3">
<div className="rounded-xl bg-muted/60 p-3">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>دریافتی</span>
<span className="text-lg font-extrabold text-foreground">{fmt(received)} تومان</span>
</div>
<div className="mt-1 flex items-center justify-between text-sm">
<span className="text-muted-foreground">{change >= 0 ? "باقی‌مانده (بازگشت)" : "کسری پرداخت"}</span>
<span className={cn("text-lg font-extrabold", change >= 0 ? "text-emerald-600" : "text-red-500")}>
{fmt(Math.abs(change))} تومان
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Chip onClick={() => setRecv(String(due))}>مبلغ دقیق</Chip>
<Chip onClick={() => setRecv(String(roundUp(50000)))}>{fmt(roundUp(50000))}</Chip>
<Chip onClick={() => setRecv(String(roundUp(100000)))}>{fmt(roundUp(100000))}</Chip>
<Chip onClick={() => setRecv(String(roundUp(500000)))}>{fmt(roundUp(500000))}</Chip>
</div>
<div className="grid grid-cols-3 gap-2">
{["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((d) => (
<Key key={d} onClick={() => press(d)}>{fmt(Number(d))}</Key>
))}
<Key onClick={() => press("000")}>۰۰۰</Key>
<Key onClick={() => press("0")}>۰</Key>
<Key onClick={backspace} aria-label="حذف"><Delete className="size-5" /></Key>
</div>
</div>
)}
{method === "card" && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<CreditCard className="size-8" />
</div>
<p className="font-medium">مبلغ به دستگاه کارتخوان ارسال میشود</p>
<p className="text-2xl font-extrabold text-primary">{fmt(due)} تومان</p>
<p className="text-sm text-muted-foreground">پس از تأیید تراکنش، دکمهٔ زیر را بزنید</p>
</div>
)}
{method === "split" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">تعداد نفرات</span>
<div className="flex items-center gap-2">
{[2, 3, 4, 5, 6].map((n) => (
<button
key={n}
type="button"
onClick={() => setSplitN(n)}
className={cn(
"size-11 cursor-pointer rounded-xl border-2 font-bold transition-colors",
splitN === n ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-accent",
)}
>
{fmt(n)}
</button>
))}
</div>
</div>
<ul className="space-y-2">
{splitAmounts.map((amt, i) => (
<li key={i} className="flex items-center justify-between gap-2 rounded-xl border border-border/70 px-3 py-2">
<span className="flex items-center gap-2 font-medium">
<span className="flex size-7 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">{fmt(i + 1)}</span>
{fmt(amt)} تومان
</span>
<div className="flex gap-1 rounded-lg border border-border p-0.5">
{(["Cash", "Card"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setSplitMethods((prev) => prev.map((x, j) => (j === i ? m : x)))}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-bold transition-colors",
(splitMethods[i] ?? "Cash") === m ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent",
)}
>
{m === "Cash" ? "نقدی" : "کارت"}
</button>
))}
</div>
</li>
))}
</ul>
</div>
)}
</div>
<div className="border-t border-border p-3">
<button
type="button"
disabled={!canConfirm}
onClick={confirm}
className="flex min-h-[56px] w-full items-center justify-center gap-2 rounded-xl bg-primary text-lg font-extrabold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 active:scale-[0.99]"
>
<Check className="size-6" />
{method === "cash" && change > 0 && received > 0
? `تأیید — بازگشت ${fmt(change)}`
: `تأیید پرداخت ${fmt(due)} تومان`}
</button>
</div>
</div>
</div>
);
}
function BusyOverlay() {
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/20">
<div className="flex items-center gap-2 rounded-xl bg-card px-4 py-3 shadow-lg">
<Loader2 className="size-5 animate-spin text-primary" />
<span className="font-medium">در حال پردازش</span>
</div>
</div>
);
}
function Key({ children, onClick, ...rest }: { children: React.ReactNode; onClick: () => void } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
onClick={onClick}
className="flex min-h-[56px] cursor-pointer items-center justify-center rounded-xl bg-muted text-xl font-bold transition-colors hover:bg-accent active:scale-95"
{...rest}
>
{children}
</button>
);
}
function Chip({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="cursor-pointer rounded-full border border-border bg-background px-3 py-2 text-sm font-medium transition-colors hover:border-primary hover:text-primary active:scale-95"
>
{children}
</button>
);
}
function Row({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
return (
<div className={cn("flex items-center justify-between", accent ? "text-emerald-600" : "text-muted-foreground")}>
<span>{label}</span>
<span>{value}</span>
</div>
);
}