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

اقلام سبد با تغییر تعداد و حذف، کد تخفیف و خلاصهٔ مبلغ.

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

این بلاک از ۱۰ آیتم دیگ استفاده می‌کند: buttoncontrol-sizeempty-stateiconsnumber-fieldpersianproduct-cardprogresstext-fieldtoast

پکیج‌منیجر پیش‌فرض 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/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/product-card.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.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/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/product-card.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.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/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/product-card.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.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/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/product-card.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

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

cart.tsx
"use client"

import * as React from "react"

import { CheckCircleIcon, ShieldCheckIcon, ShoppingCartIcon, TrashIcon, TruckIcon } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import {
  EmptyState,
  EmptyStateActions,
  EmptyStateDescription,
  EmptyStateTitle,
} from "@/components/ui/empty-state"
import { NumberField } from "@/components/ui/number-field"
import {
  ProductCard,
  ProductCardBody,
  ProductCardFooter,
  ProductCardMedia,
  ProductCardPrice,
  ProductCardTitle,
} from "@/components/ui/product-card"
import { Progress } from "@/components/ui/progress"
import { TextField } from "@/components/ui/text-field"
import { toast } from "@/components/ui/toast"
import { formatCurrency, formatNumber, toFaDigits } from "@/lib/persian"

type Item = { id: string; title: string; variant: string; swatch?: string; price: number; oldPrice?: number; quantity: number }

const INITIAL: Item[] = [
  { id: "1", title: "هدفون بی‌سیم دیگ پرو", variant: "مشکی", swatch: "var(--dig-graphite-900)", price: 3_490_000, oldPrice: 4_100_000, quantity: 1 },
  { id: "2", title: "کابل شارژ تایپ‌سی بافته‌شده", variant: "۱ متری", price: 180_000, quantity: 2 },
  { id: "3", title: "کیف محافظ هدفون", variant: "سرمه‌ای", swatch: "var(--dig-blue-900)", price: 420_000, quantity: 1 },
]
const SHIPPING = 45_000
const FREE_SHIPPING = 5_000_000

export default function CartBlock() {
  const [items, setItems] = React.useState(INITIAL)
  const [discount, setDiscount] = React.useState(0)
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  const count = items.reduce((sum, item) => sum + item.quantity, 0)
  const shipping = subtotal >= FREE_SHIPPING ? 0 : SHIPPING
  const remaining = Math.max(0, FREE_SHIPPING - subtotal)

  const update = (id: string, quantity: number) =>
    setItems((current) => current.map((item) => (item.id === id ? { ...item, quantity } : item)))

  if (items.length === 0) {
    return (
      <main className="mx-auto w-full max-w-3xl p-4 sm:p-6">
        <EmptyState variant="bordered" size="lg">
          <ShoppingCartIcon aria-hidden className="size-10 text-placeholder" />
          <EmptyStateTitle>سبد خرید شما خالی است</EmptyStateTitle>
          <EmptyStateDescription>محصولی که به سبد اضافه کنید این‌جا نمایش داده می‌شود.</EmptyStateDescription>
          <EmptyStateActions>
            <Button onClick={() => setItems(INITIAL)}>مشاهدهٔ محصولات</Button>
          </EmptyStateActions>
        </EmptyState>
      </main>
    )
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 sm:p-6">
        <header className="flex flex-col gap-1">
          <h1 className="text-2xl font-bold">سبد خرید</h1>
          <p className="text-sm text-muted-foreground">{toFaDigits(count)} کالا در سبد شما</p>
        </header>

        <ControlSizeProvider size="xl">
          <div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
            <section aria-label="کالاهای سبد" className="flex flex-col gap-3">
              {/* پیشرفت تا ارسالِ رایگان: انگیزهٔ افزودنِ کالا، همان‌جا که کاربر تصمیم می‌گیرد. */}
              <div className="flex flex-col gap-2.5 rounded-xl border border-border bg-card p-4">
                <div className="flex items-center gap-2 text-sm">
                  {remaining > 0 ? (
                    <TruckIcon aria-hidden className="size-5 text-muted-foreground" />
                  ) : (
                    <CheckCircleIcon aria-hidden className="size-5 text-success" />
                  )}
                  {remaining > 0 ? (
                    <span>
                      <strong className="font-semibold">{formatCurrency(remaining)}</strong> تا ارسالِ رایگان
                    </span>
                  ) : (
                    <span className="font-medium text-success-subtle-foreground">ارسالِ این سفارش رایگان است</span>
                  )}
                </div>
                <Progress
                  value={Math.min(100, (subtotal / FREE_SHIPPING) * 100)}
                  color={remaining > 0 ? "primary" : "success"}
                  size="sm"
                  aria-label="پیشرفت تا ارسال رایگان"
                />
              </div>

              {items.map((item) => (
                <ProductCard key={item.id} orientation="horizontal">
                  <ProductCardMedia />
                  <ProductCardBody>
                    <ProductCardTitle>{item.title}</ProductCardTitle>
                    <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
                      {item.swatch && (
                        <span aria-hidden className="size-3 rounded-full border border-border" style={{ background: item.swatch }} />
                      )}
                      {item.variant}
                    </span>
                    <ProductCardPrice
                      amount={formatCurrency(item.price * item.quantity)}
                      originalAmount={item.oldPrice ? formatCurrency(item.oldPrice * item.quantity) : undefined}
                      discount={item.oldPrice ? `٪${toFaDigits(Math.round((1 - item.price / item.oldPrice) * 100))}` : undefined}
                    />
                  </ProductCardBody>
                  <ProductCardFooter>
                    <NumberField
                      aria-label={`تعداد ${item.title}`}
                      value={item.quantity}
                      onValueChange={(quantity) => update(item.id, quantity)}
                      min={1}
                      max={10}
                      className="w-28"
                    />
                    <Button
                      variant="ghost"
                      size="icon"
                      aria-label={`حذف ${item.title}`}
                      onClick={(() => {
                        const index = items.findIndex((row) => row.id === item.id)
                        setItems((current) => current.filter((row) => row.id !== item.id))
                        toast(`«${item.title}» از سبد حذف شد.`, { action: { label: "برگردان", onClick: () => setItems((current) => (current.some((row) => row.id === item.id) ? current : [...current.slice(0, index), item, ...current.slice(index)])) } })
                      })}
                      className="text-muted-foreground hover:text-destructive"
                    >
                      <TrashIcon />
                    </Button>
                  </ProductCardFooter>
                </ProductCard>
              ))}
            </section>

            <aside aria-labelledby="summary-title" className="flex flex-col gap-5 rounded-xl border border-border bg-card p-5 lg:sticky lg:top-6">
              <h2 id="summary-title" className="text-lg font-semibold">
                خلاصهٔ سفارش
              </h2>
              <form
                className="flex items-end gap-2"
                onSubmit={(event) => {
                  event.preventDefault()
                  setDiscount(Math.round(subtotal * 0.1))
                  toast.success("کد تخفیف اعمال شد.")
                }}
              >
                <TextField label="کد تخفیف" name="coupon" dir="ltr" autoComplete="off" className="flex-1" />
                <Button type="submit" variant="outline">
                  اعمال
                </Button>
              </form>
              <dl className="mt-2 flex flex-col gap-4 text-sm">
                <div className="flex justify-between gap-2">
                  <dt className="text-muted-foreground">جمع کالاها ({toFaDigits(count)})</dt>
                  <dd>{formatCurrency(subtotal)}</dd>
                </div>
                {discount > 0 && (
                  <div className="flex justify-between gap-2 text-success-subtle-foreground">
                    <dt>تخفیف</dt>
                    <dd>
                      <bdi dir="ltr">−{formatNumber(discount)}</bdi> تومان
                    </dd>
                  </div>
                )}
                <div className="flex justify-between gap-2">
                  <dt className="text-muted-foreground">هزینهٔ ارسال</dt>
                  <dd>{shipping === 0 ? <span className="font-medium text-success-subtle-foreground">رایگان</span> : formatCurrency(shipping)}</dd>
                </div>
                <div className="mt-5 flex items-baseline justify-between gap-2 border-t border-border pt-8">
                  <dt className="font-semibold">مبلغ قابل پرداخت</dt>
                  <dd className="text-xl font-bold">{formatCurrency(subtotal - discount + shipping)}</dd>
                </div>
              </dl>
              <div className="mt-3 flex flex-col gap-3">
                <Button fullWidth>
                  ادامه و پرداخت
                </Button>
                <p className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
                  <ShieldCheckIcon aria-hidden className="size-4" />
                  پرداختِ امن از درگاهِ شاپرک
                </p>
              </div>
            </aside>
          </div>
        </ControlSizeProvider>
      </main>
    </ControlSizeProvider>
  )
}