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

مراحل خرید، روش ارسال، روش پرداخت و خلاصهٔ سفارش.

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

این بلاک از ۱۱ آیتم دیگ استفاده می‌کند: buttoncardcontrol-sizeiconslabelpersianradio-groupseparatorsteppertext-fieldtoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/radio-group.json https://docs.digdesign.ir/r/separator.json https://docs.digdesign.ir/r/stepper.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/card.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/radio-group.json https://docs.digdesign.ir/r/separator.json https://docs.digdesign.ir/r/stepper.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/card.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/radio-group.json https://docs.digdesign.ir/r/separator.json https://docs.digdesign.ir/r/stepper.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/card.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/radio-group.json https://docs.digdesign.ir/r/separator.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

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

checkout.tsx
"use client"

import * as React from "react"

import { CheckIcon, CreditCardIcon, ExpressIcon, TruckIcon, WalletIcon } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Separator } from "@/components/ui/separator"
import { TextField } from "@/components/ui/text-field"
import { toast } from "@/components/ui/toast"
import { Stepper, StepperItem, StepperTitle } from "@/components/ui/stepper"
import { formatCurrency, toFaDigits } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"

const ITEMS: { id: string; title: string; quantity: number; price: number; image: StockImageName }[] = [
  { id: "1", title: "گلدانِ سرامیکی دوقلو", quantity: 1, price: 3_490_000, image: "productVase" },
  { id: "2", title: "دفترِ یادداشتِ جلدسخت", quantity: 2, price: 180_000, image: "coverNotebook" },
]
const WALLET_BALANCE = 1_345_000
const SHIPPING = [
  { value: "post", label: "پست پیشتاز", note: "۳ تا ۵ روز کاری", price: 45_000, icon: <TruckIcon /> },
  { value: "express", label: "پیک سریع", note: "همان روز در تهران", price: 90_000, icon: <ExpressIcon /> },
]

/**
 * تسویه‌حساب: مراحل، نشانی انتخاب‌شده با «تغییر»، روشِ ارسال و پرداخت با کارت‌های رادیویی
 * (کیف پولِ ناکافی غیرفعال با دلیل)، و خلاصهٔ چسبان با عکسِ اقلام و کدِ تخفیف. نتیجهٔ
 * پرداخت صفحهٔ خودش را دارد.
 */
export default function CheckoutBlock() {
  const [shipping, setShipping] = React.useState("post")
  const [payment, setPayment] = React.useState("card")
  const [paying, setPaying] = React.useState(false)
  const [paid, setPaid] = React.useState(false)
  const subtotal = ITEMS.reduce((sum, item) => sum + item.price * item.quantity, 0)
  const shippingItem = SHIPPING.find((item) => item.value === shipping)!
  const total = subtotal + shippingItem.price
  // کیف پولی که موجودی‌اش به مبلغِ سفارش نمی‌رسد قابل انتخاب نیست، نه این‌که پرداخت بعداً خطا بدهد.
  const walletShort = WALLET_BALANCE < total
  const payments = [
    { value: "card", label: "درگاه بانکی", note: "همهٔ کارت‌های عضو شتاب", icon: <CreditCardIcon />, disabled: false },
    {
      value: "wallet",
      label: "کیف پول",
      note: walletShort
        ? `موجودی ${formatCurrency(WALLET_BALANCE)}؛ برای این سفارش کافی نیست`
        : `موجودی ${formatCurrency(WALLET_BALANCE)}`,
      icon: <WalletIcon />,
      disabled: walletShort,
    },
  ]

  async function pay() {
    setPaying(true)
    await new Promise((resolve) => setTimeout(resolve, 900))
    setPaying(false)
    // نتیجهٔ پرداخت صفحهٔ خودش را می‌خواهد؛ اعلانِ گوشهٔ صفحه ممکن است دیده نشود.
    setPaid(true)
    window.scrollTo({ top: 0 })
  }

  if (paid) {
    return (
      <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-lg flex-col items-center gap-6 p-4 py-12 text-center sm:p-6">
        <span
          aria-hidden
          className="flex size-16 items-center justify-center rounded-xl bg-success-subtle text-success [&_svg]:size-8"
        >
          <CheckIcon />
        </span>
        <div className="flex flex-col gap-2" role="status">
          <h1 className="text-2xl font-bold">پرداخت موفق بود</h1>
          <p className="text-muted-foreground">سفارش شما ثبت شد و پیامکِ تأیید برایتان ارسال شد.</p>
        </div>
        <Card className="w-full text-start">
          <CardContent className="flex flex-col gap-3 text-sm">
            <div className="flex justify-between gap-2">
              <span className="text-muted-foreground">کد پیگیری</span>
              <bdi dir="ltr" className="font-semibold tracking-wider">
                {toFaDigits("8821042")}
              </bdi>
            </div>
            <div className="flex justify-between gap-2">
              <span className="text-muted-foreground">مبلغ پرداخت‌شده</span>
              <span className="font-semibold">{formatCurrency(total)}</span>
            </div>
            <div className="flex justify-between gap-2">
              <span className="text-muted-foreground">روش ارسال</span>
              <span>
                {shippingItem.label} · {shippingItem.note}
              </span>
            </div>
          </CardContent>
        </Card>
        <div className="flex w-full flex-col gap-2 sm:flex-row">
          <Button className="flex-1">پیگیری سفارش</Button>
          <Button variant="outline" className="flex-1" onClick={() => setPaid(false)}>
            بازگشت به فروشگاه
          </Button>
        </div>
      </main>
      </ControlSizeProvider>
    )
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-5xl flex-col gap-8 p-4 sm:p-6">
        <h1 className="sr-only">تسویه‌حساب</h1>
        <Stepper value={2} className="mx-auto w-full max-w-xl">
          <StepperItem>
            <StepperTitle>سبد خرید</StepperTitle>
          </StepperItem>
          <StepperItem>
            <StepperTitle>آدرس</StepperTitle>
          </StepperItem>
          <StepperItem>
            <StepperTitle>پرداخت</StepperTitle>
          </StepperItem>
        </Stepper>

        <div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
          <div className="flex flex-col gap-6">
            <Card>
              <CardContent className="flex items-start gap-4">
                <div className="flex flex-1 flex-col gap-1">
                  <span className="text-sm text-muted-foreground">ارسال به</span>
                  <span className="font-semibold">خانه · مهسا کریمی</span>
                  <span className="text-sm leading-6 text-muted-foreground">تهران، خیابان ولیعصر، کوچهٔ نسترن، پلاک ۱۲، واحد ۴</span>
                </div>
                <Button variant="outline">تغییر</Button>
              </CardContent>
            </Card>
            <Card>
              <CardHeader>
                <CardTitle className="text-base">روش ارسال</CardTitle>
              </CardHeader>
              <CardContent>
                <RadioGroup name="shipping" value={shipping} onValueChange={setShipping} className="gap-3" aria-label="روش ارسال">
                  {SHIPPING.map((item) => (
                    <div key={item.value} className="group flex items-center gap-3 rounded-xl border border-border p-4 transition-colors has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary-subtle">
                      <RadioGroupItem id={`ship-${item.value}`} value={item.value} />
                      <OptionIcon>{item.icon}</OptionIcon>
                      <Label htmlFor={`ship-${item.value}`} className="flex flex-1 flex-col items-start gap-1">
                        <span>{item.label}</span>
                        <span className="text-xs leading-5 font-normal text-muted-foreground">{item.note}</span>
                      </Label>
                      <span className="text-sm tabular-nums">{formatCurrency(item.price)}</span>
                    </div>
                  ))}
                </RadioGroup>
              </CardContent>
            </Card>
            <Card>
              <CardHeader>
                <CardTitle className="text-base">روش پرداخت</CardTitle>
              </CardHeader>
              <CardContent>
                <RadioGroup name="payment" value={payment} onValueChange={setPayment} className="gap-3" aria-label="روش پرداخت">
                  {payments.map((item) => (
                    <div key={item.value} className="group flex items-center gap-3 rounded-xl border border-border p-4 transition-colors has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary-subtle">
                      <RadioGroupItem id={`pay-${item.value}`} value={item.value} disabled={item.disabled} />
                      <OptionIcon>{item.icon}</OptionIcon>
                      <Label htmlFor={`pay-${item.value}`} className="flex flex-1 flex-col items-start gap-1">
                        <span>{item.label}</span>
                        <span className={item.disabled ? "text-xs font-normal text-destructive" : "text-xs font-normal text-muted-foreground"}>
                          {item.note}
                        </span>
                      </Label>
                    </div>
                  ))}
                </RadioGroup>
              </CardContent>
            </Card>
          </div>

          <section aria-labelledby="checkout-summary" className="flex flex-col gap-5 rounded-xl bg-subtle p-6 lg:sticky lg:top-6">
            <h2 id="checkout-summary" className="font-bold">
              خلاصهٔ سفارش
            </h2>
            <ul className="flex flex-col gap-4">
              {ITEMS.map((item) => (
                <li key={item.id} className="flex items-center gap-3 text-sm">
                  {/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
                  <img src={stockImages[item.image].src} alt={stockImages[item.image].alt} className="size-14 rounded-lg object-cover" />
                  <span className="flex flex-1 flex-col gap-0.5">
                    <span className="font-medium">{item.title}</span>
                    <span className="text-muted-foreground">{toFaDigits(item.quantity)} عدد</span>
                  </span>
                  <span className="tabular-nums">{formatCurrency(item.price * item.quantity)}</span>
                </li>
              ))}
            </ul>
            <form
              className="flex items-end gap-2"
              onSubmit={(event) => {
                event.preventDefault()
                toast.error("این کد تخفیف معتبر نیست.")
              }}
            >
              <TextField label="کد تخفیف" name="coupon" dir="ltr" className="flex-1" />
              <Button type="submit" variant="outline">
                اعمال
              </Button>
            </form>
            <Separator />
            <dl className="flex flex-col gap-3 text-sm">
              <div className="flex justify-between gap-2 text-muted-foreground">
                <dt>جمع اقلام</dt>
                <dd className="tabular-nums">{formatCurrency(subtotal)}</dd>
              </div>
              <div className="flex justify-between gap-2 text-muted-foreground">
                <dt>هزینهٔ ارسال</dt>
                <dd className="tabular-nums">{formatCurrency(shippingItem.price)}</dd>
              </div>
              <div className="flex items-baseline justify-between gap-2">
                <dt className="font-semibold">مبلغ قابل پرداخت</dt>
                <dd className="text-xl font-black tabular-nums">{formatCurrency(total)}</dd>
              </div>
            </dl>
            <Button fullWidth loading={paying} onClick={pay} className="mt-3">
              پرداخت {formatCurrency(total)}
            </Button>
          </section>
        </div>
      </main>
    </ControlSizeProvider>
  )
}

function OptionIcon({ children }: { children: React.ReactNode }) {
  return (
    <span
      aria-hidden
      className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-subtle text-foreground group-has-[[data-state=checked]]:bg-background [&_svg]:size-5"
    >
      {children}
    </span>
  )
}