feat(pos): clickable POS v2 redesign prototype at /pos2 (static, no backend)
CI/CD / CI · API (dotnet build + test) (push) Failing after 3m18s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m17s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 1m2s
CI/CD / Deploy · all services (push) Has been skipped
CI/CD / CI · API (dotnet build + test) (push) Failing after 3m18s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m17s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 1m2s
CI/CD / Deploy · all services (push) Has been skipped
Responsive RTL big-touch reimagining of the POS order screen for judging the redesign on real devices before decomposing the 1568-line pos-screen.tsx + wiring real logic. Self-contained: mock menu + local cart, no API/store/SignalR. - 3 zones: category chips + item grid · order ticket · sticky action bar. - lg+ side-panel ticket; smaller screens get a "view order" bar + slide-over (covers landscape tablet, portrait tablet, phone). - Big-touch (56px primary / 44px qty), brand green #0F6E56, Toman totals. Send/Pay are mock toasts. tsc clean.
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
import { Pos2Prototype } from "@/components/pos2/pos2-prototype";
|
||||||
|
|
||||||
|
/** POS v2 — clickable static prototype (mock data, no backend). Open at /<locale>/pos2
|
||||||
|
* on a tablet/phone to judge the redesigned layout before we decompose + wire it. */
|
||||||
|
export default function Pos2PrototypePage() {
|
||||||
|
return <Pos2Prototype />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// POS v2 — clickable STATIC prototype (mock data, local state only).
|
||||||
|
// Zero coupling to the live POS: no API, no stores, no SignalR. Purpose: judge the
|
||||||
|
// layout/feel on real devices before we decompose + wire. Route: /[locale]/pos2
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Search, Plus, Minus, Trash2, Send, CreditCard, Pause, SplitSquareHorizontal,
|
||||||
|
X, WifiOff, ShoppingCart, Users, Coffee,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { notify } from "@/lib/notify";
|
||||||
|
|
||||||
|
type Item = { id: string; name: string; price: number; cat: string };
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: "all", name: "همه" },
|
||||||
|
{ id: "coffee", name: "قهوه" },
|
||||||
|
{ id: "tea", name: "دمنوش" },
|
||||||
|
{ id: "cold", name: "نوشیدنی سرد" },
|
||||||
|
{ id: "food", name: "غذا" },
|
||||||
|
{ id: "dessert", name: "دسر" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ITEMS: Item[] = [
|
||||||
|
{ id: "1", name: "اسپرسو", price: 55000, cat: "coffee" },
|
||||||
|
{ id: "2", name: "آمریکانو", price: 65000, cat: "coffee" },
|
||||||
|
{ id: "3", name: "کاپوچینو", price: 85000, cat: "coffee" },
|
||||||
|
{ id: "4", name: "لاته", price: 92000, cat: "coffee" },
|
||||||
|
{ id: "5", name: "موکا", price: 98000, cat: "coffee" },
|
||||||
|
{ id: "6", name: "چای ماسالا", price: 72000, cat: "tea" },
|
||||||
|
{ id: "7", name: "دمنوش بهلیمو", price: 68000, cat: "tea" },
|
||||||
|
{ id: "8", name: "آیس لاته", price: 105000, cat: "cold" },
|
||||||
|
{ id: "9", name: "فراپه کارامل", price: 135000, cat: "cold" },
|
||||||
|
{ id: "10", name: "لیموناد", price: 88000, cat: "cold" },
|
||||||
|
{ id: "11", name: "سالاد سزار", price: 185000, cat: "food" },
|
||||||
|
{ id: "12", name: "ساندویچ مرغ", price: 165000, cat: "food" },
|
||||||
|
{ id: "13", name: "پاستا آلفردو", price: 220000, cat: "food" },
|
||||||
|
{ id: "14", name: "چیزکیک", price: 145000, cat: "dessert" },
|
||||||
|
{ id: "15", name: "براونی", price: 120000, cat: "dessert" },
|
||||||
|
{ id: "16", name: "تیرامیسو", price: 155000, cat: "dessert" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Line = { item: Item; qty: number };
|
||||||
|
const fmt = (n: number) => n.toLocaleString("fa-IR");
|
||||||
|
const TAX = 0.09;
|
||||||
|
|
||||||
|
export function Pos2Prototype() {
|
||||||
|
const [cat, setCat] = useState("all");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [lines, setLines] = useState<Line[]>([]);
|
||||||
|
const [cartOpen, setCartOpen] = useState(false); // mobile/portrait slide-over
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() => ITEMS.filter((i) => (cat === "all" || i.cat === cat) && (q === "" || i.name.includes(q))),
|
||||||
|
[cat, q],
|
||||||
|
);
|
||||||
|
const count = lines.reduce((s, l) => s + l.qty, 0);
|
||||||
|
const subtotal = lines.reduce((s, l) => s + l.qty * l.item.price, 0);
|
||||||
|
const tax = Math.round(subtotal * TAX);
|
||||||
|
const total = subtotal + tax;
|
||||||
|
|
||||||
|
const add = (it: Item) =>
|
||||||
|
setLines((ls) => {
|
||||||
|
const e = ls.find((l) => l.item.id === it.id);
|
||||||
|
return e
|
||||||
|
? ls.map((l) => (l.item.id === it.id ? { ...l, qty: l.qty + 1 } : l))
|
||||||
|
: [...ls, { item: it, qty: 1 }];
|
||||||
|
});
|
||||||
|
const bump = (id: string, d: number) =>
|
||||||
|
setLines((ls) => ls.flatMap((l) => (l.item.id === id ? (l.qty + d <= 0 ? [] : [{ ...l, qty: l.qty + d }]) : [l])));
|
||||||
|
const remove = (id: string) => setLines((ls) => ls.filter((l) => l.item.id !== id));
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
if (!count) return;
|
||||||
|
notify.success("سفارش به آشپزخانه ارسال شد (نمونه)");
|
||||||
|
setLines([]);
|
||||||
|
setCartOpen(false);
|
||||||
|
};
|
||||||
|
const pay = () => {
|
||||||
|
if (!count) return;
|
||||||
|
notify.success(`پرداخت ${fmt(total)} تومان (نمونه)`);
|
||||||
|
setLines([]);
|
||||||
|
setCartOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div dir="rtl" className="flex h-svh min-h-0 flex-col bg-background text-foreground">
|
||||||
|
{/* ── Topbar ───────────────────────────────────────────── */}
|
||||||
|
<header className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl bg-primary/10 px-3 py-1.5 text-primary">
|
||||||
|
<Coffee className="size-5" />
|
||||||
|
<span className="font-bold">میز ۵</span>
|
||||||
|
</div>
|
||||||
|
<span className="hidden items-center gap-1.5 text-sm text-muted-foreground sm:flex">
|
||||||
|
<Users className="size-4" /> ۲ نفر
|
||||||
|
</span>
|
||||||
|
<div className="relative mx-2 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>
|
||||||
|
<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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Body: menu zone + ticket (lg side panel) ─────────── */}
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* MENU ZONE */}
|
||||||
|
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
|
{/* category chips — horizontal scroll, works all sizes */}
|
||||||
|
<div className="flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCat(c.id)}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full px-4 py-2.5 text-sm font-medium transition-colors min-h-[44px] cursor-pointer",
|
||||||
|
cat === c.id
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* item grid */}
|
||||||
|
<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 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{items.map((it) => (
|
||||||
|
<button
|
||||||
|
key={it.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => add(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>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p className="col-span-full py-10 text-center text-muted-foreground">آیتمی یافت نشد</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* ORDER TICKET — side panel on lg+, hidden below */}
|
||||||
|
<aside className="hidden w-[360px] shrink-0 border-s border-border bg-card lg:flex lg:flex-col">
|
||||||
|
<Ticket
|
||||||
|
lines={lines}
|
||||||
|
subtotal={subtotal}
|
||||||
|
tax={tax}
|
||||||
|
total={total}
|
||||||
|
count={count}
|
||||||
|
onBump={bump}
|
||||||
|
onRemove={remove}
|
||||||
|
onSend={send}
|
||||||
|
onPay={pay}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Mobile/portrait: sticky "view order" bar ─────────── */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Mobile/portrait: slide-over ticket ───────────────── */}
|
||||||
|
{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">سفارش میز ۵</span>
|
||||||
|
<button type="button" onClick={() => setCartOpen(false)} className="rounded-lg p-2 hover:bg-accent">
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Ticket
|
||||||
|
lines={lines}
|
||||||
|
subtotal={subtotal}
|
||||||
|
tax={tax}
|
||||||
|
total={total}
|
||||||
|
count={count}
|
||||||
|
onBump={bump}
|
||||||
|
onRemove={remove}
|
||||||
|
onSend={send}
|
||||||
|
onPay={pay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ticket({
|
||||||
|
lines, subtotal, tax, total, count, onBump, onRemove, onSend, onPay,
|
||||||
|
}: {
|
||||||
|
lines: Line[]; subtotal: number; tax: number; total: number; count: number;
|
||||||
|
onBump: (id: string, d: number) => void; onRemove: (id: string) => void;
|
||||||
|
onSend: () => void; onPay: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
{/* line items */}
|
||||||
|
<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.item.id} className="flex items-center gap-2 rounded-xl border border-border/70 p-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="line-clamp-1 font-medium">{l.item.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{fmt(l.item.price)} تومان</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button type="button" onClick={() => onBump(l.item.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.qty)}</span>
|
||||||
|
<button type="button" onClick={() => onBump(l.item.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.item.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>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* totals */}
|
||||||
|
<div className="space-y-1 border-t border-border px-4 py-3 text-sm">
|
||||||
|
<Row label="جمع" value={`${fmt(subtotal)} تومان`} />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* action bar */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 border-t border-border p-3">
|
||||||
|
<button type="button" disabled={!count} 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" /> ارسال
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!count} 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>
|
||||||
|
<button type="button" disabled={!count} 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">
|
||||||
|
<Pause className="size-4" /> نگهداشتن
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!count} 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user