بلاک‌ها · رزرو و خدمات

رزرو چندمرحله‌ای (مرحلهٔ زمان): تقویمِ شمسی، ساعت‌ها و شعبه با کارت‌های رادیویی و حالتِ پر شده، خلاصهٔ چسبانِ نوبت با پزشک، هزینه و یادآوری.

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

این بلاک از ۱۳ آیتم دیگ استفاده می‌کند: avatarbadgebuttoncalendarcardcontrol-sizeiconslabelpersianradio-groupstepperswitchtoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/calendar.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/stepper.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/toast.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/calendar.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/stepper.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/toast.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/calendar.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/stepper.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/toast.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/calendar.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/stepper.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

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

appointment-booking.tsx
"use client"

import * as React from "react"

import { AvatarGenderIcon, ClockIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
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 { Stepper, StepperItem, StepperTitle } from "@/components/ui/stepper"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
import { formatCurrency, formatJalali, toFaDigits } from "@/lib/persian"
import { stockImages } from "@/lib/stock-images"

const SLOTS = ["09:00", "09:30", "10:00", "11:30", "16:00", "16:30", "17:00", "18:30"]
const FULL = new Set(["10:00", "16:30"])
const BRANCHES = [
  { value: "valiasr", title: "شعبهٔ ولیعصر", address: "خیابان ولیعصر، بالاتر از پارک ساعی، پلاک ۱۲", hours: "امروز باز است · ۹ تا ۱۹" },
  { value: "tajrish", title: "شعبهٔ تجریش", address: "میدان تجریش، خیابان دربند، پلاک ۴", hours: "امروز باز است · ۱۰ تا ۲۰" },
]
const FEE = 850_000

/** کارتِ انتخاب برای رادیو؛ کلِ کارت کلیک‌پذیر است و حالتِ انتخاب با حاشیه و زمینه دیده می‌شود. */
const optionCard =
  "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 has-[:disabled]:bg-muted has-[:disabled]:text-muted-foreground"

/**
 * رزرو نوبت در چند گام: مرحلهٔ فعلی «زمان نوبت». روز از تقویم، ساعت و شعبه با
 * کارت‌های رادیویی، و خلاصهٔ نوبت با یادآوری در ستونِ کناری که با هر انتخاب به‌روز
 * می‌شود. «ادامه» تا روز و ساعت انتخاب نشده‌اند غیرفعال است.
 */
export default function AppointmentBookingBlock() {
  const today = React.useMemo(() => {
    const now = new Date()
    now.setHours(0, 0, 0, 0)
    return now
  }, [])
  const [date, setDate] = React.useState<Date | undefined>(today)
  const [slot, setSlot] = React.useState("")
  const confirm = () => toast.success("زمان نوبت ثبت شد.", { description: `${date ? formatJalali(date, "long") : ""} · ساعت ${toFaDigits(slot)}` })
  const [branch, setBranch] = React.useState(BRANCHES[0].value)
  const [remind, setRemind] = React.useState(true)
  const selectedBranch = BRANCHES.find((item) => item.value === branch)

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 pb-28 sm:p-6 sm:pb-28 lg:pb-6">
        <header className="flex flex-col items-center gap-2 text-center">
          <h1 className="text-3xl font-bold">رزرو نوبت</h1>
          <p className="text-muted-foreground">همهٔ چیزی که پیش از مراجعه لازم است، در سه دقیقه.</p>
        </header>

        <Stepper value={1} className="mx-auto w-full max-w-2xl">
          <StepperItem>
            <StepperTitle>اطلاعات شما</StepperTitle>
          </StepperItem>
          <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]">
          <Card>
            <CardContent className="grid grid-cols-1 gap-10 md:grid-cols-[auto_minmax(0,1fr)]">
              <section aria-labelledby="booking-day" className="flex flex-col gap-4">
                <h2 id="booking-day" className="font-semibold">
                  روز
                </h2>
                <Calendar
                  calendar="jalali"
                  mode="single"
                  selected={date}
                  onSelect={(next) => {
                    setDate(next)
                    setSlot("")
                  }}
                  disabled={[{ before: today }, { dayOfWeek: [5] }]}
                  className="rounded-xl border border-border [--cell-size:--spacing(9)] sm:[--cell-size:--spacing(11)]"
                />
              </section>

              <div className="flex flex-col gap-10">
                <section aria-labelledby="booking-time" className="flex flex-col gap-4">
                  <div className="flex items-baseline justify-between gap-2">
                    <h2 id="booking-time" className="font-semibold">
                      ساعت
                    </h2>
                    <span className="text-sm text-muted-foreground">{date ? formatJalali(date, "long") : "اول روز را انتخاب کنید"}</span>
                  </div>
                  <RadioGroup value={slot} onValueChange={setSlot} aria-labelledby="booking-time" className="grid grid-cols-2 gap-3 sm:grid-cols-4">
                    {SLOTS.map((time) => {
                      const full = FULL.has(time)
                      return (
                        <div key={time} className={optionCard}>
                          <RadioGroupItem id={`slot-${time}`} value={time} disabled={full || !date} />
                          <Label htmlFor={`slot-${time}`} className="flex flex-1 flex-col items-start gap-0.5 font-medium tabular-nums">
                            <span className="flex items-center gap-1.5">
                              <ClockIcon aria-hidden className="size-4 text-muted-foreground" />
                              {toFaDigits(time)}
                            </span>
                            {full && <span className="text-xs font-normal">پر شده</span>}
                          </Label>
                        </div>
                      )
                    })}
                  </RadioGroup>
                </section>

                <section aria-labelledby="booking-branch" className="flex flex-col gap-4">
                  <h2 id="booking-branch" className="font-semibold">
                    شعبه
                  </h2>
                  <RadioGroup value={branch} onValueChange={setBranch} aria-labelledby="booking-branch" className="gap-3">
                    {BRANCHES.map((item) => (
                      <div key={item.value} className={`${optionCard} items-start`}>
                        <RadioGroupItem id={`branch-${item.value}`} value={item.value} className="mt-1" />
                        <Label htmlFor={`branch-${item.value}`} className="flex flex-1 flex-col items-start gap-1">
                          <span className="font-semibold">{item.title}</span>
                          <span className="text-sm leading-6 font-normal text-muted-foreground">{item.address}</span>
                          <span className="text-xs font-normal text-success">{item.hours}</span>
                        </Label>
                      </div>
                    ))}
                  </RadioGroup>
                </section>
              </div>
            </CardContent>
          </Card>

          {/* خلاصه: پزشک، انتخاب‌ها، هزینه و یادآوری؛ دکمهٔ ادامه با فاصلهٔ بیشتر. */}
          <Card className="lg:sticky lg:top-6">
            <CardHeader>
              <CardTitle>خلاصهٔ نوبت</CardTitle>
            </CardHeader>
            <CardContent className="flex flex-col gap-6">
              {/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
              <img src={stockImages.clinicRoom.src} alt={stockImages.clinicRoom.alt} className="aspect-[16/9] w-full rounded-lg object-cover" />
              <div className="flex items-center gap-3">
                <Avatar size="lg" alt="دکتر آرمیتا رستگار" src={stockImages.personWoman3.src} icon={<AvatarGenderIcon gender="female" />} />
                <div className="flex flex-col gap-0.5">
                  <span className="font-semibold">دکتر آرمیتا رستگار</span>
                  <span className="text-sm text-muted-foreground">متخصص پوست و مو</span>
                </div>
                <Badge variant="soft" color="success" className="ms-auto">
                  حضوری
                </Badge>
              </div>

              <dl className="flex flex-col divide-y divide-border text-sm">
                {[
                  { label: "روز", value: date ? formatJalali(date, "long") : "انتخاب نشده" },
                  { label: "ساعت", value: slot ? toFaDigits(slot) : "انتخاب نشده" },
                  { label: "شعبه", value: selectedBranch?.title ?? "انتخاب نشده" },
                  { label: "هزینهٔ ویزیت", value: formatCurrency(FEE) },
                ].map((row) => (
                  <div key={row.label} className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
                    <dt className="text-muted-foreground">{row.label}</dt>
                    <dd className="font-medium">{row.value}</dd>
                  </div>
                ))}
              </dl>

              <div className="flex items-start gap-3 rounded-xl bg-muted p-4">
                <div className="flex flex-1 flex-col gap-0.5">
                  <Label htmlFor="booking-remind">یادآوری</Label>
                  <span className="text-xs leading-5 text-muted-foreground">یک روز قبل پیامک می‌شود.</span>
                </div>
                <Switch id="booking-remind" checked={remind} onCheckedChange={setRemind} />
              </div>

              <Button fullWidth className="mt-2 hidden lg:flex" disabled={!date || !slot} onClick={confirm}>
                ادامه
              </Button>
            </CardContent>
          </Card>
        </div>

        {/* در موبایل دکمهٔ اصلی پایینِ صفحه چسبیده است تا بعد از انتخابِ ساعت لازم نباشد تا تهِ خلاصه اسکرول شود. */}
        <div className="fixed inset-x-0 bottom-0 z-20 flex items-center gap-4 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))] lg:hidden">
          <span className="flex min-w-0 flex-1 flex-col">
            <span className="text-xs text-muted-foreground">نوبتِ شما</span>
            <span className="truncate text-sm font-semibold">{date && slot ? `${formatJalali(date, "long")} · ساعت ${toFaDigits(slot)}` : "روز و ساعت را انتخاب کن"}</span>
          </span>
          <Button disabled={!date || !slot} onClick={confirm} className="shrink-0">
            ادامه
          </Button>
        </div>
      </main>
    </ControlSizeProvider>
  )
}