نوبتدهی
پیشنمایش تمامصفحهرزرو چندمرحلهای (مرحلهٔ زمان): تقویمِ شمسی، ساعتها و شعبه با کارتهای رادیویی و حالتِ پر شده، خلاصهٔ چسبانِ نوبت با پزشک، هزینه و یادآوری.
۱. نصب کامپوننتها
این بلاک از ۱۳ آیتم دیگ استفاده میکند: 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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
)
}