انتخابِ زمانِ نوبت
پیشنمایش تمامصفحهنوارِ هفت روزِ پیشِ رو با شمارِ نوبتِ خالی، ساعتهای صبح و عصر با RadioCard و ساعتِ پر، راهنمای نزدیکترین روزِ خالی برای روزِ پر یا تعطیل، و نوارِ پایین با خلاصهٔ انتخاب و ادامه.
۱. نصب کامپوننتها
این بلاک از ۱۰ آیتم دیگ استفاده میکند: avatarbuttoncontrol-sizeempty-stateiconspersianradio-cardradio-grouptoasttoggle-group
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.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/persian.json https://docs.digdesign.ir/r/radio-card.json https://docs.digdesign.ir/r/radio-group.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.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/persian.json https://docs.digdesign.ir/r/radio-card.json https://docs.digdesign.ir/r/radio-group.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.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/persian.json https://docs.digdesign.ir/r/radio-card.json https://docs.digdesign.ir/r/radio-group.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/avatar.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/persian.json https://docs.digdesign.ir/r/radio-card.json https://docs.digdesign.ir/r/radio-group.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
doctor-slots.tsx
"use client"
import * as React from "react"
import { CalendarIcon, ChevronRightIcon, LocationIcon, MoonIcon, SunIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateTitle } from "@/components/ui/empty-state"
import { RadioCard } from "@/components/ui/radio-card"
import { RadioGroup } from "@/components/ui/radio-group"
import { toast } from "@/components/ui/toast"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { formatCurrency, toFaDigits } from "@/lib/persian"
import { stockImages } from "@/lib/stock-images"
type Slot = { time: string; full?: boolean }
type Day = { id: string; weekday: string; date: string; morning: Slot[]; evening: Slot[] }
/** هفت روزِ پیشِ رو؛ ساعتهای پر نمایش داده میشوند ولی انتخابشدنی نیستند تا کاربر تصویرِ درستی از ظرفیت داشته باشد. */
const DAYS: Day[] = [
{ id: "d0", weekday: "امروز", date: "۲۵ مهر", morning: [], evening: [{ time: "۱۶:۳۰" }, { time: "۱۷:۰۰", full: true }, { time: "۱۸:۱۵" }] },
{ id: "d1", weekday: "یکشنبه", date: "۲۶ مهر", morning: [], evening: [{ time: "۱۶:۰۰" }, { time: "۱۶:۴۵" }, { time: "۱۷:۳۰", full: true }, { time: "۱۸:۰۰" }, { time: "۱۹:۱۵" }] },
{ id: "d2", weekday: "دوشنبه", date: "۲۷ مهر", morning: [], evening: [{ time: "۱۶:۰۰", full: true }, { time: "۱۷:۱۵", full: true }, { time: "۱۹:۰۰" }] },
{ id: "d3", weekday: "سهشنبه", date: "۲۸ مهر", morning: [], evening: [] },
{ id: "d4", weekday: "چهارشنبه", date: "۲۹ مهر", morning: [{ time: "۰۹:۰۰" }, { time: "۰۹:۳۰" }, { time: "۱۰:۱۵" }, { time: "۱۱:۰۰", full: true }, { time: "۱۲:۰۰" }], evening: [] },
{ id: "d5", weekday: "پنجشنبه", date: "۳۰ مهر", morning: [], evening: [] },
{ id: "d6", weekday: "شنبه", date: "۲ آبان", morning: [], evening: [{ time: "۱۶:۰۰" }, { time: "۱۶:۳۰" }, { time: "۱۸:۴۵" }] },
]
const free = (day: Day) => [...day.morning, ...day.evening].filter((slot) => !slot.full).length
/**
* انتخابِ زمانِ نوبت: سرِ صفحه پزشک و مطب، نوارِ هفت روزِ پیشِ رو با شمارِ نوبتِ خالیِ هر روز (روزِ بینوبت
* «تعطیل» یا «پر» نوشته میشود و انتخابشدنی است تا پیامش دیده شود)، و ساعتها در دو گروهِ صبح و عصر با
* RadioCard؛ ساعتِ پر خطخورده نیست، «پر» مینویسد و غیرفعال است. انتخاب در نوارِ پایین خلاصه میشود و
* «ادامه» تا ساعت انتخاب نشده پیامِ راهنما میدهد، نه دکمهٔ مرده.
*/
export default function DoctorSlotsBlock() {
const [dayId, setDayId] = React.useState("d0")
const [time, setTime] = React.useState("")
const day = DAYS.find((item) => item.id === dayId) ?? DAYS[0]
const nextOpen = DAYS.find((item) => DAYS.indexOf(item) > DAYS.indexOf(day) && free(item) > 0)
const groups = [
{ id: "morning", label: "صبح", icon: <SunIcon />, slots: day.morning },
{ id: "evening", label: "عصر", icon: <MoonIcon />, slots: day.evening },
].filter((group) => group.slots.length > 0)
return (
<ControlSizeProvider size="xl" asChild>
<div className="mx-auto flex w-full max-w-3xl flex-col">
<main className="flex flex-col gap-8 p-4 sm:p-6">
<header className="flex items-center gap-3">
<Button variant="ghost" size="icon" aria-label="بازگشت به پروفایلِ پزشک" className="-ms-2 shrink-0">
<ChevronRightIcon className="ltr:rotate-180" />
</Button>
<Avatar src={stockImages.doctorWomanCurly.src} alt="" name="سارا محمدی" className="size-12 shrink-0" />
<div className="flex min-w-0 flex-col">
<h1 className="truncate leading-snug font-bold">دکتر سارا محمدی</h1>
<span className="flex items-center gap-1 truncate text-sm text-muted-foreground">
<LocationIcon aria-hidden className="size-4 shrink-0" />
ویزیتِ حضوری · مطبِ ونک
</span>
</div>
</header>
<section aria-labelledby="day-title" className="flex flex-col gap-3">
<h2 id="day-title" className="text-lg leading-snug font-bold">
روزِ نوبت
</h2>
{/* نوارِ روزها اسکرولِ افقی دارد؛ لبهٔ کارتها ۴ پیکسل پدینگ میگیرد تا حلقهٔ فوکوس بریده نشود. */}
<div className="-mx-4 overflow-x-auto px-4 py-1 [scrollbar-width:none] sm:mx-0 sm:px-0">
<ToggleGroup
type="single"
variant="outline"
separated
value={dayId}
onValueChange={(value) => {
if (!value) return
setDayId(value as string)
setTime("")
}}
aria-label="روزِ نوبت"
className="w-max gap-2"
>
{DAYS.map((item) => {
const count = free(item)
const closed = item.morning.length + item.evening.length === 0
return (
<ToggleGroupItem
key={item.id}
value={item.id}
aria-label={`${item.weekday} ${item.date}، ${closed ? "تعطیل" : count ? `${toFaDigits(count)} نوبتِ خالی` : "پر"}`}
className="h-auto! w-20 flex-col gap-0.5 rounded-xl! px-2 py-2.5 data-[state=on]:border-primary data-[state=on]:bg-primary-subtle data-[state=on]:text-foreground"
>
<span className="text-xs text-muted-foreground">{item.weekday}</span>
<span className="font-bold">{item.date}</span>
<span className={`text-xs ${closed || !count ? "text-muted-foreground" : "font-medium text-success"}`}>{closed ? "تعطیل" : count ? `${toFaDigits(count)} خالی` : "پر"}</span>
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
</section>
<section aria-labelledby="time-title" className="flex flex-col gap-5">
<h2 id="time-title" className="text-lg leading-snug font-bold">
ساعتِ نوبت
</h2>
{free(day) === 0 ? (
<EmptyState variant="bordered">
<EmptyStateTitle>{groups.length ? "نوبتهای این روز پر شده" : "مطب این روز تعطیل است"}</EmptyStateTitle>
<EmptyStateDescription>{nextOpen ? `نزدیکترین روزِ خالی ${nextOpen.weekday}، ${nextOpen.date} است.` : "این هفته نوبتِ خالی نمانده؛ میتوانی مشاورهٔ تلفنی بگیری."}</EmptyStateDescription>
{nextOpen && (
<EmptyStateActions>
<Button variant="outline" onClick={() => setDayId(nextOpen.id)}>
رفتن به {nextOpen.weekday}
</Button>
</EmptyStateActions>
)}
</EmptyState>
) : (
<RadioGroup value={time} onValueChange={setTime} aria-labelledby="time-title" className="flex flex-col gap-5">
{groups.map((group) => (
<div key={group.id} className="flex flex-col gap-3">
<span className="flex items-center gap-2 text-sm font-medium text-muted-foreground [&>svg]:size-4">
{group.icon}
{group.label}
</span>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{group.slots.map((slot) => (
<RadioCard
key={slot.time}
value={slot.time}
layout="tile"
disabled={slot.full}
title={<span className="tabular-nums">{slot.time}</span>}
description={slot.full ? "پر" : undefined}
/>
))}
</div>
</div>
))}
</RadioGroup>
)}
</section>
</main>
{/* خلاصه و ادامه در دسترسِ شست؛ در دسکتاپ هم پایینِ ستون میچسبد. */}
<div className="sticky bottom-0 flex items-center gap-4 border-t border-border bg-background px-4 py-3 sm:px-6">
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1.5 text-sm font-semibold [&>svg]:size-4 [&>svg]:shrink-0">
<CalendarIcon aria-hidden />
<span className="truncate">{time ? `${day.weekday} ${day.date}، ساعت ${time}` : "ساعت را انتخاب کن"}</span>
</span>
<span className="text-xs text-muted-foreground tabular-nums">ویزیت {formatCurrency(850_000)}</span>
</span>
<Button
className="min-w-32"
onClick={() => {
if (!time) {
toast("اول یکی از ساعتهای خالی را انتخاب کن.")
document.getElementById("time-title")?.scrollIntoView({ behavior: "smooth", block: "center" })
return
}
toast.success(`نوبتِ ${day.weekday} ساعت ${time} نگه داشته شد؛ ۱۰ دقیقه برای تکمیل وقت داری.`)
}}
>
ادامه
</Button>
</div>
</div>
</ControlSizeProvider>
)
}