بلاک‌ها · پنل مدیریت

فروشِ سریعِ لمسی: نوعِ سفارش، دسته‌ها و جست‌وجو، کاشی‌های غذا با عکس و قیمت و ناموجود، و صورت‌حساب با کم و زیادِ تعداد، مالیات و ثبت و چاپ.

۱. نصب کامپوننت‌ها

این بلاک از ۷ آیتم دیگ استفاده می‌کند: 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.json
pnpm 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.json
yarn 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.json
bunx --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>
  );
}