"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 = { 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 = { 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(() => { 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(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(null); const [payLoyalty, setPayLoyalty] = useState(0); // Order just paid — kept after the cart is cleared so the receipt stays printable. const [paidOrderId, setPaidOrderId] = useState(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(`/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 => { 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(`/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(`/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 (
); } const offlineBadge = !online && ( آفلاین ); 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 (
{busy && }
{/* Dashboard exit — lets user navigate back without the sidebar */}
میزها
{fmt(occupied)} فعال · {fmt(Math.max(0, list.length - occupied))} خالی
{offlineBadge}
{tablesLoading ? (
) : list.length === 0 ? (

هنوز میزی تعریف نشده است.

) : (
{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 ( ); })}
)}
); } // ── ORDER SCREEN ─────────────────────────────────────────────────────────── const title = takeaway ? "بیرون‌بر" : `میز ${activeTable?.number ?? ""}`; return (
{busy && }
{takeaway ? : } {title}
{activeOrderNo ? ( سفارش #{fmt(activeOrderNo)} ) : null}
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" />
{offlineBadge}
{/* ── Left: vertical category sidebar (desktop) ── */} {/* ── Center: menu items ── */}
{/* Horizontal chips — mobile only */}
{catChips.map((c) => ( ))}
{menuLoading ? (
) : menuError ? (

بارگذاری منو ناموفق بود.

) : (
{visibleItems.map((it) => ( ))} {visibleItems.length === 0 && (

آیتمی یافت نشد

)}
)}
{/* ── Right: order ticket (desktop) ── */}
{count > 0 && ( )} {cartOpen && (
setCartOpen(false)} />
سفارش {title}
)} {payTarget && ( setPayTarget(null)} onConfirm={confirmPay} /> )} {paidOrderId && (
{ setPaidOrderId(null); backToBoard(); }} />

پرداخت با موفقیت ثبت شد

فاکتور مشتری را می‌توانید چاپ کنید

)}
); } // ── 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(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 (
{appliedCoupon ? (
{appliedCoupon.code} (−{fmt(appliedCoupon.discountAmount)})
) : (
{ 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" />
)} {msg ?

{msg}

: null}
); } // ── 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(null); return (
{lines.length === 0 ? (

سبد خالی است

برای افزودن، روی آیتم‌ها بزنید

) : (
    {lines.map((l) => (
  • {l.menuItem.name}

    {fmt(l.menuItem.price)} تومان

    {fmt(l.quantity)}
    {l.synced ? ( // Already sent to the kitchen — note is read-only (can't be changed now). l.notes ? (

    {l.notes}

    ) : null ) : noteFor === l.menuItem.id || l.notes ? ( 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" /> ) : ( )}
  • ))}
)}
{discount > 0 && }
مبلغ کل {fmt(total)} تومان
); } // ── 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(["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(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 (

پرداخت — {tableName}

{fmt(due)} تومان {redeem > 0 && {fmt(amountDue)}}

{TABS.map((t) => ( ))}
{loyaltyPoints > 0 && ( )} {method === "cash" && (
دریافتی {fmt(received)} تومان
{change >= 0 ? "باقی‌مانده (بازگشت)" : "کسری پرداخت"} = 0 ? "text-emerald-600" : "text-red-500")}> {fmt(Math.abs(change))} تومان
setRecv(String(due))}>مبلغ دقیق setRecv(String(roundUp(50000)))}>{fmt(roundUp(50000))} setRecv(String(roundUp(100000)))}>{fmt(roundUp(100000))} setRecv(String(roundUp(500000)))}>{fmt(roundUp(500000))}
{["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((d) => ( press(d)}>{fmt(Number(d))} ))} press("000")}>۰۰۰ press("0")}>۰
)} {method === "card" && (

مبلغ به دستگاه کارت‌خوان ارسال می‌شود

{fmt(due)} تومان

پس از تأیید تراکنش، دکمهٔ زیر را بزنید

)} {method === "split" && (
تعداد نفرات
{[2, 3, 4, 5, 6].map((n) => ( ))}
    {splitAmounts.map((amt, i) => (
  • {fmt(i + 1)} {fmt(amt)} تومان
    {(["Cash", "Card"] as const).map((m) => ( ))}
  • ))}
)}
); } function BusyOverlay() { return (
در حال پردازش…
); } function Key({ children, onClick, ...rest }: { children: React.ReactNode; onClick: () => void } & React.ButtonHTMLAttributes) { return ( ); } function Chip({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { return ( ); } function Row({ label, value, accent }: { label: string; value: string; accent?: boolean }) { return (
{label} {value}
); }