صندوقِ فروش
پیشنمایش تمامصفحهفروشِ سریعِ لمسی: نوعِ سفارش، دستهها و جستوجو، کاشیهای غذا با عکس و قیمت و ناموجود، و صورتحساب با کم و زیادِ تعداد، مالیات و ثبت و چاپ.
۱. نصب کامپوننتها
این بلاک از ۷ آیتم دیگ استفاده میکند: buttoncontrol-sizeiconspersiansearch-fieldtoasttoggle-group
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
orders-pos.tsx
"use client";
import * as React from "react";
import { MinusIcon, PlusIcon, TrashIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ControlSizeProvider } from "@/components/ui/control-size";
import { SearchField } from "@/components/ui/search-field";
import { toast } from "@/components/ui/toast";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { formatCurrency, toFaDigits, toSearchKey } from "@/lib/persian";
import { stockImages, type StockImageName } from "@/lib/stock-images";
const CATEGORIES = [
{ id: "burger", label: "برگر" },
{ id: "pizza", label: "پیتزا" },
{ id: "salad", label: "سالاد" },
{ id: "drink", label: "نوشیدنی" },
];
const MENU: {
id: string;
category: string;
title: string;
price: number;
available: boolean;
image: StockImageName;
}[] = [
{
id: "m1",
category: "burger",
title: "برگرِ کلاسیک",
price: 280_000,
available: true,
image: "foodDish",
},
{
id: "m2",
category: "burger",
title: "چیزبرگرِ دوبل",
price: 360_000,
available: true,
image: "foodDish",
},
{
id: "m3",
category: "burger",
title: "برگرِ قارچ",
price: 320_000,
available: false,
image: "foodDish",
},
{
id: "m4",
category: "burger",
title: "مرغِ سوخاری",
price: 300_000,
available: true,
image: "foodDish",
},
{
id: "m5",
category: "pizza",
title: "پیتزا مخلوط",
price: 420_000,
available: true,
image: "restaurant",
},
{
id: "m6",
category: "pizza",
title: "پیتزا سبزیجات",
price: 380_000,
available: true,
image: "restaurant",
},
{
id: "m7",
category: "salad",
title: "سالادِ سزار",
price: 210_000,
available: true,
image: "foodDish",
},
{
id: "m8",
category: "drink",
title: "لیموناد",
price: 90_000,
available: true,
image: "coverMinimalRoom",
},
];
const TAX_RATE = 0.1;
/**
* صندوقِ فروشِ رستوران (POS) برای صفحهٔ لمسی: نوعِ سفارش (سالن، بیرونبر، پیک)، دستهها با شمار، جستوجو،
* کاشیهای غذا با عکس و قیمت (ناموجودها غیرفعال با برچسب)، و صورتحسابِ کنار با کم و زیاد کردنِ تعداد،
* مالیات و جمعِ قابلِ پرداخت و «ثبت و چاپ» که تا خالی بودنِ صورتحساب غیرفعال است.
*/
export default function OrdersPosBlock() {
const [mode, setMode] = React.useState("dine");
const [category, setCategory] = React.useState("burger");
const [query, setQuery] = React.useState("");
const [ticket, setTicket] = React.useState<Record<string, number>>({
m1: 2,
m8: 1,
});
const items = MENU.filter((item) =>
query
? toSearchKey(item.title).includes(toSearchKey(query))
: item.category === category,
);
const lines = MENU.filter((item) => ticket[item.id]);
const subtotal = lines.reduce(
(sum, item) => sum + item.price * ticket[item.id],
0,
);
const tax = Math.round(subtotal * TAX_RATE);
const change = (id: string, delta: number) =>
setTicket((current) => {
const next = { ...current, [id]: (current[id] ?? 0) + delta };
if (next[id] <= 0) delete next[id];
return next;
});
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto grid w-full max-w-6xl grid-cols-1 items-start gap-6 p-4 sm:p-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div className="flex min-w-0 flex-col gap-8">
<header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-6">
<h1 className="text-2xl leading-snug font-black">فروشِ سریع</h1>
<ToggleGroup
type="single"
variant="segmented"
value={mode}
onValueChange={(value) => value && setMode(value as string)}
aria-label="نوعِ سفارش"
>
<ToggleGroupItem value="dine">سالن</ToggleGroupItem>
<ToggleGroupItem value="takeaway">بیرونبر</ToggleGroupItem>
<ToggleGroupItem value="delivery">پیک</ToggleGroupItem>
</ToggleGroup>
</header>
{/* جستوجو و دستهها یک گروهاند (نزدیک)؛ سربرگ و کاشیها با فاصلهٔ بیشتر جدا. */}
<div className="flex flex-col gap-4">
<SearchField
aria-label="جستوجوی غذا"
placeholder="نامِ غذا"
value={query}
onValueChange={setQuery}
/>
<div className="-mx-4 overflow-x-auto px-4 [scrollbar-width:none] sm:mx-0 sm:px-0">
<ToggleGroup
type="single"
variant="outline"
separated
value={query ? "" : category}
onValueChange={(value) =>
value && (setCategory(value as string), setQuery(""))
}
aria-label="دستهها"
className="w-max gap-2"
>
{CATEGORIES.map((item) => (
<ToggleGroupItem
key={item.id}
value={item.id}
className="gap-2 rounded-full! px-4 data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground"
>
{item.label}
<span className="text-xs tabular-nums">
{toFaDigits(
MENU.filter((menu) => menu.category === item.id).length,
)}
</span>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</div>
{items.length === 0 ? (
<p className="rounded-xl bg-subtle p-10 text-center text-muted-foreground">
غذایی با «{query}» پیدا نشد.
</p>
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
{items.map((item) => (
<li key={item.id}>
<Button
variant="outline"
disabled={!item.available}
onClick={() => change(item.id, 1)}
aria-label={
item.available
? `افزودنِ ${item.title}`
: `${item.title}، ناموجود`
}
className="relative h-auto w-full flex-col items-stretch gap-2 rounded-xl bg-card p-2 text-start font-normal whitespace-normal shadow-card disabled:bg-subtle"
>
{/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
<img
src={stockImages[item.image].src}
alt=""
loading="lazy"
className={`aspect-[4/3] w-full rounded-lg object-cover ${item.available ? "" : "grayscale"}`}
/>
<span className="flex flex-col gap-0.5 px-1 pb-1">
<span className="font-bold">{item.title}</span>
<span className="text-sm text-muted-foreground tabular-nums">
{item.available
? formatCurrency(item.price)
: "ناموجود"}
</span>
</span>
{ticket[item.id] && (
<span className="absolute start-3 top-3 flex size-7 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground tabular-nums">
{toFaDigits(ticket[item.id])}
</span>
)}
</Button>
</li>
))}
</ul>
)}
</div>
{/* صورتحساب: در دسکتاپ چسبان کنارِ منو، در موبایل زیرِ آن. */}
<aside
aria-labelledby="ticket-title"
className="flex flex-col gap-5 rounded-xl border border-border bg-card p-5 shadow-card lg:sticky lg:top-6"
>
<div className="flex items-center justify-between">
<h2 id="ticket-title" className="text-lg font-bold">
صورتحساب
</h2>
<span className="text-sm text-muted-foreground">میزِ ۷</span>
</div>
{lines.length === 0 ? (
<p className="rounded-lg bg-subtle p-6 text-center text-sm text-muted-foreground">
هنوز چیزی اضافه نشده؛ روی غذا بزن.
</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{lines.map((item) => (
<li
key={item.id}
className="flex items-center gap-3 py-3 first:pt-0"
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-semibold">{item.title}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{formatCurrency(item.price * ticket[item.id])}
</span>
</span>
<ControlSizeProvider
size="lg"
className="flex items-center gap-1"
>
<Button
variant="outline"
size="icon"
aria-label={`افزایشِ ${item.title}`}
onClick={() => change(item.id, 1)}
>
<PlusIcon />
</Button>
<span
aria-live="polite"
className="w-6 text-center font-bold tabular-nums"
>
{toFaDigits(ticket[item.id])}
</span>
<Button
variant="outline"
size="icon"
aria-label={
ticket[item.id] === 1
? `حذفِ ${item.title}`
: `کاهشِ ${item.title}`
}
onClick={() => change(item.id, -1)}
>
{ticket[item.id] === 1 ? <TrashIcon /> : <MinusIcon />}
</Button>
</ControlSizeProvider>
</li>
))}
</ul>
)}
<dl className="flex flex-col gap-2 rounded-lg bg-subtle p-4 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">جمع</dt>
<dd className="tabular-nums">{formatCurrency(subtotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">مالیات (٪۱۰)</dt>
<dd className="tabular-nums">{formatCurrency(tax)}</dd>
</div>
<div className="flex justify-between border-t border-border pt-2 text-base">
<dt className="font-bold">قابلِ پرداخت</dt>
<dd className="font-black tabular-nums">
{formatCurrency(subtotal + tax)}
</dd>
</div>
</dl>
<div className="mt-3 grid grid-cols-2 gap-3">
<Button
variant="outline"
disabled={lines.length === 0}
onClick={() => setTicket({})}
>
پاک کردن
</Button>
<Button
disabled={lines.length === 0}
onClick={() => toast.success("سفارش ثبت و برای آشپزخانه چاپ شد.")}
>
ثبت و چاپ
</Button>
</div>
</aside>
</main>
</ControlSizeProvider>
);
}