بلاک‌ها · فروشگاهی

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

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

این بلاک از ۱۰ آیتم دیگ استفاده می‌کند: badgebuttoncontrol-sizeempty-stateiconsnumber-fieldpersianprogresssheettoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/toast.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/toast.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/toast.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/number-field.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

کد را در app/<route>/page.tsx بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

cart-drawer.tsx
"use client"

import * as React from "react"

import { ShoppingCartIcon, TrashIcon, TruckIcon } from "@/components/icons"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { EmptyState, EmptyStateDescription, EmptyStateTitle } from "@/components/ui/empty-state"
import { NumberField } from "@/components/ui/number-field"
import { Progress } from "@/components/ui/progress"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { toast } from "@/components/ui/toast"
import { formatCurrency, toFaDigits } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"

type Item = { id: string; title: string; variant: string; image: StockImageName; price: number; quantity: number }

const INITIAL: Item[] = [
  { id: "1", title: "گلدانِ سرامیکی دوقلو", variant: "کرم", image: "productVase", price: 1_290_000, quantity: 1 },
  { id: "2", title: "دفترِ یادداشتِ جلدسخت", variant: "A5", image: "coverNotebook", price: 180_000, quantity: 2 },
  { id: "3", title: "ست لوازم‌التحریرِ رومیزی", variant: "طوسی", image: "coverOfficeSupplies", price: 640_000, quantity: 1 },
]
const FREE_SHIPPING = 3_000_000

/**
 * سبدِ کشویی: با «افزودن به سبد» از لبهٔ پایانی باز می‌شود و کاربر از صفحه بیرون
 * نمی‌رود. اقلام با عکس و تعداد، نوارِ «تا ارسالِ رایگان»، جمع و دکمهٔ تسویه که همیشه
 * پایینِ کشو می‌ماند؛ حذف با امکانِ برگرداندن.
 */
export default function CartDrawerBlock() {
  const [open, setOpen] = React.useState(true)
  const [items, setItems] = React.useState(INITIAL)
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  const count = items.reduce((sum, item) => sum + item.quantity, 0)
  const remaining = Math.max(0, FREE_SHIPPING - subtotal)

  function remove(item: Item) {
    setItems((list) => list.filter((entry) => entry.id !== item.id))
    toast(`«${item.title}» از سبد حذف شد.`, {
      action: { label: "برگردان", onClick: () => setItems((list) => (list.some((entry) => entry.id === item.id) ? list : [...list, item])) },
    })
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <div className="mx-auto flex min-h-svh w-full max-w-5xl flex-col gap-8 p-4 sm:p-6">
        <header className="flex items-center justify-between gap-4">
          <span className="flex items-center gap-2 text-lg font-black">
            <span aria-hidden className="size-3 rounded-sm bg-[var(--dig-orange-500)]" />
            خانهٔ سفال
          </span>
          <Button variant="outline" onClick={() => setOpen(true)} aria-label={`سبد خرید، ${toFaDigits(count)} کالا`}>
            <ShoppingCartIcon />
            سبد
            {count > 0 && <Badge color="primary">{toFaDigits(count)}</Badge>}
          </Button>
        </header>

        <main className="grid grid-cols-1 items-center gap-8 md:grid-cols-2">
          {/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
          <img src={stockImages.productVase.src} alt={stockImages.productVase.alt} className="aspect-square w-full rounded-xl object-cover" />
          <div className="flex flex-col gap-6">
            <div className="flex flex-col gap-2">
              <h1 className="text-3xl font-bold">گلدانِ سرامیکی دوقلو</h1>
              <span className="text-2xl font-black tabular-nums">{formatCurrency(1_290_000)}</span>
            </div>
            <p className="leading-8 text-muted-foreground">دست‌سازِ کارگاهِ لالجین، لعابِ مات و مناسبِ گلِ طبیعی و خشک.</p>
            <Button
              className="mt-2 self-start"
              onClick={() => {
                setItems((list) => list.map((item) => (item.id === "1" ? { ...item, quantity: Math.min(item.quantity + 1, 10) } : item)))
                setOpen(true)
              }}
            >
              <ShoppingCartIcon />
              افزودن به سبد
            </Button>
          </div>
        </main>

        <Sheet open={open} onOpenChange={setOpen}>
          <ControlSizeProvider size="xl" asChild>
            <SheetContent side="end" className="flex w-full max-w-md flex-col gap-0 p-0">
              <SheetHeader className="gap-4 border-b border-border p-6">
                <SheetTitle>سبد خرید ({toFaDigits(count)})</SheetTitle>
                <SheetDescription className="sr-only">اقلامِ سبد و جمعِ مبلغ</SheetDescription>
                {items.length > 0 && (
                  <div className="flex flex-col gap-2 rounded-lg bg-subtle p-4">
                    <span className="flex items-center gap-2 text-sm">
                      <TruckIcon aria-hidden className="size-4 text-brand-secondary" />
                      {remaining > 0 ? <>{formatCurrency(remaining)} تا ارسالِ رایگان</> : "ارسالِ این سفارش رایگان است"}
                    </span>
                    <Progress value={Math.min(100, (subtotal / FREE_SHIPPING) * 100)} aria-label="پیشرفت تا ارسالِ رایگان" />
                  </div>
                )}
              </SheetHeader>

              {items.length === 0 ? (
                <EmptyState className="flex-1">
                  <ShoppingCartIcon aria-hidden className="size-10 text-placeholder" />
                  <EmptyStateTitle>سبد خالی است</EmptyStateTitle>
                  <EmptyStateDescription>هر چه اضافه کنید این‌جا می‌آید.</EmptyStateDescription>
                </EmptyState>
              ) : (
                <ul className="flex flex-1 flex-col divide-y divide-border overflow-y-auto px-6">
                  {items.map((item) => (
                    <li key={item.id} className="flex gap-4 py-5">
                      {/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
                      <img src={stockImages[item.image].src} alt={stockImages[item.image].alt} className="size-20 shrink-0 rounded-lg object-cover" />
                      <div className="flex flex-1 flex-col gap-3">
                        <div className="flex items-start justify-between gap-2">
                          <span className="flex flex-col gap-0.5">
                            <span className="font-medium">{item.title}</span>
                            <span className="text-sm text-muted-foreground">{item.variant}</span>
                          </span>
                          <Button variant="ghost" size="icon" aria-label={`حذفِ ${item.title}`} onClick={() => remove(item)}>
                            <TrashIcon />
                          </Button>
                        </div>
                        <div className="flex items-center justify-between gap-2">
                          <NumberField
                            aria-label={`تعداد ${item.title}`}
                            value={item.quantity}
                            onValueChange={(quantity) => setItems((list) => list.map((entry) => (entry.id === item.id ? { ...entry, quantity } : entry)))}
                            min={1}
                            max={10}
                            className="w-32"
                          />
                          <span className="font-bold tabular-nums">{formatCurrency(item.price * item.quantity)}</span>
                        </div>
                      </div>
                    </li>
                  ))}
                </ul>
              )}

              {items.length > 0 && (
                <SheetFooter className="gap-5 border-t border-border p-6">
                  <div className="flex items-baseline justify-between gap-2">
                    <span className="text-muted-foreground">جمعِ سبد</span>
                    <span className="text-xl font-black tabular-nums">{formatCurrency(subtotal)}</span>
                  </div>
                  <div className="flex flex-col gap-2">
                    <Button fullWidth>ادامهٔ خرید و تسویه</Button>
                    <Button variant="ghost" fullWidth onClick={() => setOpen(false)}>
                      ادامهٔ گشت‌وگذار
                    </Button>
                  </div>
                </SheetFooter>
              )}
            </SheetContent>
          </ControlSizeProvider>
        </Sheet>
      </div>
    </ControlSizeProvider>
  )
}