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

انتخابِ زمانِ نوبت

پیش‌نمایش تمام‌صفحه

نوارِ هفت روزِ پیشِ رو با شمارِ نوبتِ خالی، ساعت‌های صبح و عصر با 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.json
pnpm 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.json
yarn 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.json
bunx --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>
  )
}