feat(pos): POS v2 feature parity + promote to default /pos
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m46s

Completes the four POS v2 roadmap items:

1. Real split payments — split tab records N separate payment rows (equal split,
   last row takes the remainder), each row toggles Cash/Card; posts payments[].
2. Card-terminal push — confirmPay sums Card amounts and calls requestPosPayment
   (POS device) before recording; surfaces POS_DEVICE_* errors.
3. Customer + coupons + loyalty — reuses PosCustomerPicker (attach/search/create)
   and validates coupons via /coupons/validate (discount in totals). Pay sheet
   offers loyalty redemption (1 point = 100 toman) when a customer is attached.
4. Promote to default — /pos now renders POS v2 (full-screen, café-themed); the
   classic terminal moves to /pos-classic with its sidebar+topbar chrome. The
   "نسخه کلاسیک" link points there.

Order submission already carried customerId/guestName/guestPhone/couponId via the
shared cart store, so customer + coupon flow straight through send + pay.
tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 05:16:52 +03:30
parent 75a0a1c834
commit 6778c32028
5 changed files with 332 additions and 108 deletions
@@ -0,0 +1,50 @@
"use client";
import { useLocale } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
/**
* Classic POS route layout — wraps the terminal in the standard dashboard
* chrome (collapsible sidebar + topbar) but keeps the main content area
* overflow-hidden so PosScreen can manage its own internal scrolling.
*/
export default function PosClassicLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = useLocale();
const isRtl = locale !== "en";
const mainColumn = (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<Topbar />
<main className="min-h-0 flex-1 overflow-hidden bg-background p-3 md:p-4">
{children}
</main>
</div>
);
return (
<CafeThemeProvider>
<div
className="flex h-screen min-h-0 overflow-hidden bg-background"
dir={isRtl ? "rtl" : "ltr"}
>
{isRtl ? (
<>
<Sidebar side="right" />
{mainColumn}
</>
) : (
<>
<Sidebar side="left" />
{mainColumn}
</>
)}
</div>
</CafeThemeProvider>
);
}
@@ -0,0 +1,12 @@
import { Suspense } from "react";
import { PosScreen } from "@/components/pos/pos-screen";
/** Classic POS terminal — chrome (sidebar + topbar) is provided by layout.tsx.
* Kept as a fallback while POS v2 (at /pos) is piloted. */
export default function PosClassicPage() {
return (
<Suspense fallback={null}>
<PosScreen />
</Suspense>
);
}
@@ -1,50 +1,13 @@
"use client";
import { useLocale } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
/**
* POS route layout — wraps the terminal in the standard dashboard chrome
* (collapsible sidebar + topbar) but keeps the main content area
* overflow-hidden so PosScreen can manage its own internal scrolling.
* POS v2 layout — the redesigned terminal is full-screen (its own topbar +
* order ticket), so no dashboard sidebar/topbar chrome here. Café theming
* still applies. Auth guarding comes from the parent (fullscreen) layout.
* The classic POS keeps its chrome under /pos-classic.
*/
export default function PosLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = useLocale();
const isRtl = locale !== "en";
const mainColumn = (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<Topbar />
<main className="min-h-0 flex-1 overflow-hidden bg-background p-3 md:p-4">
{children}
</main>
</div>
);
return (
<CafeThemeProvider>
<div
className="flex h-screen min-h-0 overflow-hidden bg-background"
dir={isRtl ? "rtl" : "ltr"}
>
{isRtl ? (
<>
<Sidebar side="right" />
{mainColumn}
</>
) : (
<>
<Sidebar side="left" />
{mainColumn}
</>
)}
</div>
</CafeThemeProvider>
);
export default function PosLayout({ children }: { children: React.ReactNode }) {
return <CafeThemeProvider>{children}</CafeThemeProvider>;
}
@@ -1,11 +1,8 @@
import { Suspense } from "react";
import { PosScreen } from "@/components/pos/pos-screen";
import { Pos2Screen } from "@/components/pos2/pos2-screen";
/** POS terminal — chrome (sidebar + topbar) is provided by layout.tsx */
/** Default POS terminal — redesigned v2, wired to live data (menu, tables,
* orders, payments) via the shared cart store + offline submit pipeline.
* The classic POS remains available at /[locale]/pos-classic. */
export default function PosPage() {
return (
<Suspense fallback={null}>
<PosScreen />
</Suspense>
);
return <Pos2Screen />;
}
+257 -55
View File
@@ -4,15 +4,16 @@
// 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]/pos2. Design mirrors components/pos2/pos2-prototype.tsx.
// Mounted at /[locale]/pos (and /pos2). Design mirrors pos2-prototype.tsx.
// ─────────────────────────────────────────────────────────────────────────────
import { useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Search, Plus, Minus, Trash2, Send, CreditCard, Pause, SplitSquareHorizontal,
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2, RotateCcw,
BadgePercent, Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify";
@@ -22,13 +23,42 @@ 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/pos/submit-order";
import type { MenuItem, Order, TableBoardItem } from "@/lib/api/types";
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
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();
@@ -48,10 +78,10 @@ export function Pos2Screen() {
const removeItem = useCartStore((s) => s.removeItem);
const setTableId = useCartStore((s) => s.setTableId);
const setOrderType = useCartStore((s) => s.setOrderType);
const setActiveOrderId = useCartStore((s) => s.setActiveOrderId);
const hydrateFromOrder = useCartStore((s) => s.hydrateFromOrder);
const clearSession = useCartStore((s) => s.clearSession);
const activeOrderNo = useCartStore((s) => s.activeOrderDisplayNumber);
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
// local view state
const [view, setView] = useState<"board" | "order">("board");
@@ -62,6 +92,7 @@ export function Pos2Screen() {
const [cartOpen, setCartOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [payTarget, setPayTarget] = useState<Order | null>(null);
const [payLoyalty, setPayLoyalty] = useState(0);
const [online, setOnline] = useState(true);
useEffect(() => {
@@ -74,8 +105,10 @@ export function Pos2Screen() {
const live = items.filter((l) => !l.isVoided);
const subtotal = live.reduce((s, l) => s + l.menuItem.price * l.quantity, 0);
const tax = Math.round(subtotal * TAX);
const total = subtotal + tax;
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)),
@@ -183,17 +216,26 @@ export function Pos2Screen() {
}
}
if (!order) {
// offline/local order — pay against the optimistic total
const cart = useCartStore.getState();
order = {
id: cart.activeOrderId ?? "local_pending",
cafeId: cafeId as string,
orderType: "DineIn", status: "Open",
subtotal, taxTotal: tax, discountAmount: 0, total,
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, "آماده‌سازی پرداخت ناموفق بود"));
@@ -202,19 +244,33 @@ export function Pos2Screen() {
}
};
const confirmPay = async (method: "Cash" | "Card", amount: number) => {
const confirmPay = async (payments: Payment[], loyaltyRedeem: number) => {
if (!payTarget) return;
setBusy(true);
try {
const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0);
const payBranchId = payTarget.branchId ?? branchId ?? 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: [{ method, amount }],
payments,
loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined,
});
notify.success(`پرداخت ${fmt(amount)} تومان ثبت شد`);
const paid = payments.reduce((s, p) => s + p.amount, 0);
notify.success(`پرداخت ${fmt(paid)} تومان ثبت شد`);
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
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 {
notify.error(errMsg(e, "ثبت پرداخت ناموفق بود"));
}
} finally {
setBusy(false);
}
@@ -235,6 +291,12 @@ export function Pos2Screen() {
</span>
);
const ticketProps = {
cafeId, lines: live, 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,
};
// ── TABLE BOARD ────────────────────────────────────────────────────────────
if (view === "board") {
const list = tables ?? [];
@@ -254,7 +316,7 @@ export function Pos2Screen() {
{offlineBadge}
<button
type="button"
onClick={() => router.push("/pos")}
onClick={() => router.push("/pos-classic")}
className="hidden 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 sm:flex"
>
<RotateCcw className="size-4" /> نسخه کلاسیک
@@ -318,7 +380,7 @@ export function Pos2Screen() {
<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" : cleaning ? "text-muted-foreground" : "text-muted-foreground")}>
<span className={cn("text-sm font-medium", reserved ? "text-amber-700" : "text-muted-foreground")}>
{reserved ? "رزرو" : cleaning ? "در حال تمیزکاری" : "خالی"}
</span>
)}
@@ -411,13 +473,8 @@ export function Pos2Screen() {
)}
</main>
<aside className="hidden w-[360px] shrink-0 border-s border-border bg-card lg:flex lg:flex-col">
<Ticket
lines={live} subtotal={subtotal} tax={tax} total={total} count={count}
pendingCount={pendingCount}
onBump={(id, d) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); }}
onRemove={removeItem} onSend={send} onPay={openPay} onSplit={openPay}
/>
<aside className="hidden w-[380px] shrink-0 border-s border-border bg-card lg:flex lg:flex-col">
<Ticket {...ticketProps} />
</aside>
</div>
@@ -446,12 +503,7 @@ export function Pos2Screen() {
<X className="size-5" />
</button>
</div>
<Ticket
lines={live} subtotal={subtotal} tax={tax} total={total} count={count}
pendingCount={pendingCount}
onBump={(id, d) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); }}
onRemove={removeItem} onSend={send} onPay={openPay} onSplit={openPay}
/>
<Ticket {...ticketProps} />
</div>
</div>
)}
@@ -460,6 +512,7 @@ export function Pos2Screen() {
<Pos2PaySheet
tableName={title}
amountDue={orderAmountDue(payTarget) || total}
loyaltyPoints={payLoyalty}
onClose={() => setPayTarget(null)}
onConfirm={confirmPay}
/>
@@ -468,12 +521,94 @@ export function Pos2Screen() {
);
}
// ── 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 };
function Ticket({
lines, subtotal, tax, total, count, pendingCount, onBump, onRemove, onSend, onPay, onSplit,
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onSend, onPay, onSplit,
}: {
lines: TicketLine[]; subtotal: number; tax: number; total: number; count: number; pendingCount: number;
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;
onSend: () => void; onPay: () => void; onSplit: () => void;
}) {
@@ -512,8 +647,11 @@ function Ticket({
)}
</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>
@@ -542,25 +680,50 @@ function Ticket({
);
}
// ── Payment sheet ────────────────────────────────────────────────────────────
// ── Payment sheet (cash / card / split + numpad + loyalty) ───────────────────
function Pos2PaySheet({
tableName, amountDue, onClose, onConfirm,
tableName, amountDue, loyaltyPoints, onClose, onConfirm,
}: {
tableName: string; amountDue: number;
tableName: string; amountDue: number; loyaltyPoints: number;
onClose: () => void;
onConfirm: (method: "Cash" | "Card", amount: number) => void;
onConfirm: (payments: Payment[], loyaltyRedeem: number) => void;
}) {
const [method, setMethod] = useState<"cash" | "card" | "split">("cash");
const [recv, setRecv] = useState("");
const [splitN, setSplitN] = useState(2);
const [splitMethods, setSplitMethods] = useState<Method[]>(["Cash", "Cash"]);
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 - amountDue;
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(amountDue / step) * step;
const perPerson = Math.ceil(amountDue / splitN / 1000) * 1000;
const canConfirm = method === "cash" ? received >= amountDue : true;
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);
};
const TABS = [
{ id: "cash", name: "نقدی", icon: Banknote },
@@ -568,16 +731,17 @@ function Pos2PaySheet({
{ id: "split", name: "تقسیم", icon: SplitSquareHorizontal },
] as const;
const confirm = () => onConfirm(method === "card" ? "Card" : "Cash", amountDue);
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 w-full flex-col bg-card shadow-2xl sm:max-w-md sm:rounded-2xl">
<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(amountDue)} تومان</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" />
@@ -600,7 +764,27 @@ function Pos2PaySheet({
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
<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">
@@ -616,7 +800,7 @@ function Pos2PaySheet({
</div>
</div>
<div className="flex flex-wrap gap-2">
<Chip onClick={() => setRecv(String(amountDue))}>مبلغ دقیق</Chip>
<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>
@@ -637,8 +821,8 @@ function Pos2PaySheet({
<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(amountDue)} تومان</p>
<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>
)}
@@ -646,7 +830,7 @@ function Pos2PaySheet({
{method === "split" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">تقسیم بین</span>
<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
@@ -663,13 +847,31 @@ function Pos2PaySheet({
))}
</div>
</div>
<div className="rounded-xl bg-muted/60 p-3 text-center">
<p className="text-sm text-muted-foreground">سهم هر نفر</p>
<p className="text-2xl font-extrabold text-primary">{fmt(perPerson)} تومان</p>
<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>
<p className="text-center text-xs text-muted-foreground">
کل مبلغ یکجا ثبت میشود؛ تقسیم صرفاً برای راهنمایی صندوقدار است.
</p>
</li>
))}
</ul>
</div>
)}
</div>
@@ -682,9 +884,9 @@ function Pos2PaySheet({
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
{method === "cash" && change > 0 && received > 0
? `تأیید — بازگشت ${fmt(change)}`
: `تأیید پرداخت ${fmt(amountDue)} تومان`}
: `تأیید پرداخت ${fmt(due)} تومان`}
</button>
</div>
</div>
@@ -728,9 +930,9 @@ function Chip({ children, onClick }: { children: React.ReactNode; onClick: () =>
);
}
function Row({ label, value }: { label: string; value: string }) {
function Row({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
return (
<div className="flex items-center justify-between text-muted-foreground">
<div className={cn("flex items-center justify-between", accent ? "text-emerald-600" : "text-muted-foreground")}>
<span>{label}</span>
<span>{value}</span>
</div>