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

جست‌وجوی پزشک با نام، تخصص یا بیماری، چیپِ تخصص، نوبتِ امروز و ویزیتِ آنلاین، مرتب‌سازی؛ کارتِ پزشک با عکس، شمارهٔ نظام پزشکی، امتیاز، مطب و فاصله و نزدیک‌ترین نوبت، و «خبرم کن» برای پزشکِ بی‌نوبت.

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

این بلاک از ۱۱ آیتم دیگ استفاده می‌کند: avatarbadgebuttoncontrol-sizeempty-stateiconspersiansearch-fieldselectswitchtoggle-group

پکیج‌منیجر پیش‌فرض 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/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/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/switch.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/badge.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/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/switch.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/badge.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/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/switch.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/badge.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/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/switch.json https://docs.digdesign.ir/r/toggle-group.json

۲. کد صفحه

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

doctor-list.tsx
"use client"

import * as React from "react"

import { CalendarIcon, ChatIcon, LocationIcon, StarIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateTitle } from "@/components/ui/empty-state"
import { SearchField } from "@/components/ui/search-field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { toFaDigits, toSearchKey } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"

type Doctor = {
  id: string
  name: string
  specialty: string
  group: string
  photo: StockImageName
  council: string
  rating: number
  reviews: number
  clinic: string
  km: number
  online: boolean
  /** نزدیک‌ترین نوبت؛ dayOffset صفر یعنی امروز. */
  next: { dayOffset: number; time: string } | null
}

const DOCTORS: Doctor[] = [
  { id: "1", name: "دکتر سارا محمدی", specialty: "متخصصِ قلب و عروق", group: "heart", photo: "doctorWomanCurly", council: "۱۲۳۴۵۶", rating: 4.8, reviews: 412, clinic: "ونک، ساختمانِ پزشکانِ آرین", km: 1.2, online: true, next: { dayOffset: 0, time: "۱۶:۳۰" } },
  { id: "2", name: "دکتر علی رضایی", specialty: "پزشکِ عمومی", group: "general", photo: "doctorManGlasses", council: "۲۳۴۵۶۷", rating: 4.6, reviews: 289, clinic: "یوسف‌آباد، کلینیکِ سینا", km: 2.4, online: true, next: { dayOffset: 1, time: "۱۰:۰۰" } },
  { id: "3", name: "دکتر مریم کاظمی", specialty: "متخصصِ پوست و مو", group: "skin", photo: "doctorWomanRed", council: "۳۴۵۶۷۸", rating: 4.9, reviews: 530, clinic: "سعادت‌آباد، کلینیکِ پوستِ نیکان", km: 4.1, online: false, next: { dayOffset: 3, time: "۱۱:۳۰" } },
  { id: "4", name: "دکتر رضا احمدی", specialty: "متخصصِ کودکان", group: "kids", photo: "doctorManScrubs", council: "۴۵۶۷۸۹", rating: 4.7, reviews: 198, clinic: "پاسداران، کلینیکِ آوا", km: 3.0, online: true, next: { dayOffset: 0, time: "۱۸:۰۰" } },
  { id: "5", name: "دکتر کامران نوری", specialty: "متخصصِ چشم", group: "eye", photo: "doctorManOffice", council: "۵۶۷۸۹۰", rating: 4.5, reviews: 147, clinic: "ولیعصر، بیمارستانِ نور", km: 5.6, online: false, next: null },
]

const GROUPS = [
  { value: "all", label: "همه" },
  { value: "general", label: "عمومی" },
  { value: "heart", label: "قلب" },
  { value: "skin", label: "پوست" },
  { value: "kids", label: "کودکان" },
  { value: "eye", label: "چشم" },
]

const dayLabel = (offset: number) => (offset === 0 ? "امروز" : offset === 1 ? "فردا" : `${toFaDigits(offset)} روزِ دیگر`)

/**
 * فهرستِ پزشکان برای نوبت‌دهیِ آنلاین: جست‌وجو با نام، تخصص یا بیماری، چیپِ تخصص، «فقط نوبتِ امروز» و
 * «ویزیتِ آنلاین»، و مرتب‌سازی. کارتِ هر پزشک عکس، تخصص و شمارهٔ نظام پزشکی (برای اعتماد)، امتیاز با تعدادِ
 * نظر، مطب و فاصله، و نزدیک‌ترین نوبت را دارد؛ نوبتِ امروز سبز است. پزشکِ بی‌نوبت دکمهٔ «خبرم کن» دارد، نه
 * دکمهٔ غیرفعال. نتیجهٔ خالی راهِ برگشت دارد.
 */
export default function DoctorListBlock() {
  const [query, setQuery] = React.useState("")
  const [group, setGroup] = React.useState("all")
  const [today, setToday] = React.useState(false)
  const [online, setOnline] = React.useState(false)
  const [sort, setSort] = React.useState("soonest")
  const key = toSearchKey(query)

  const visible = DOCTORS.filter(
    (doctor) =>
      (!key || toSearchKey(doctor.name + doctor.specialty).includes(key)) &&
      (group === "all" || doctor.group === group) &&
      (!today || doctor.next?.dayOffset === 0) &&
      (!online || doctor.online)
  ).sort((a, b) => (sort === "rating" ? b.rating - a.rating : sort === "nearest" ? a.km - b.km : (a.next?.dayOffset ?? 99) - (b.next?.dayOffset ?? 99)))

  const reset = () => {
    setQuery("")
    setGroup("all")
    setToday(false)
    setOnline(false)
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 sm:p-6">
        <header className="flex flex-col gap-1">
          <h1 className="text-2xl leading-snug font-bold sm:text-3xl">نوبتِ پزشک</h1>
          <p className="text-muted-foreground">از بیش از ۴۰۰ پزشکِ تهران، بی‌تماس و بی‌صف.</p>
        </header>

        <div className="flex flex-col gap-4">
          <SearchField aria-label="جست‌وجوی پزشک" placeholder="نامِ پزشک، تخصص یا بیماری" value={query} onValueChange={setQuery} />
          {/* تخصص‌ها چیپ‌اند (۳۶، در گوشی ۳۲) و می‌شکنند؛ گزینه‌ای در لبه بریده نمی‌شود. */}
          <ControlSizeProvider size="default" mobileSize="sm" asChild>
            <ToggleGroup type="single" variant="outline" separated value={group} onValueChange={(value) => value && setGroup(value as string)} aria-label="تخصص" className="flex-wrap gap-2">
              {GROUPS.map((item) => (
                <ToggleGroupItem key={item.value} value={item.value} className="rounded-full! px-3.5 text-sm">
                  {item.label}
                </ToggleGroupItem>
              ))}
            </ToggleGroup>
          </ControlSizeProvider>
          <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
            <div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
              <label className="flex cursor-pointer items-center gap-2">
                <Switch checked={today} onCheckedChange={setToday} />
                فقط نوبتِ امروز
              </label>
              <label className="flex cursor-pointer items-center gap-2">
                <Switch checked={online} onCheckedChange={setOnline} />
                ویزیتِ آنلاین
              </label>
            </div>
            <ControlSizeProvider size="lg" mobileSize="default" asChild>
              <div className="flex items-center gap-2 text-sm">
                <span id="doctor-sort" className="text-muted-foreground">
                  مرتب‌سازی
                </span>
                <Select value={sort} onValueChange={setSort}>
                  <SelectTrigger aria-labelledby="doctor-sort" className="w-auto min-w-36">
                    <SelectValue />
                  </SelectTrigger>
                  <ControlSizeProvider size="xl" asChild>
                    <SelectContent>
                      <SelectItem value="soonest">نزدیک‌ترین نوبت</SelectItem>
                      <SelectItem value="rating">بیشترین امتیاز</SelectItem>
                      <SelectItem value="nearest">نزدیک‌ترین مطب</SelectItem>
                    </SelectContent>
                  </ControlSizeProvider>
                </Select>
              </div>
            </ControlSizeProvider>
          </div>
        </div>

        <p className="text-sm text-muted-foreground" aria-live="polite">
          {toFaDigits(visible.length)} پزشک
        </p>

        {visible.length === 0 ? (
          <EmptyState variant="bordered">
            <EmptyStateTitle>پزشکی با این شرایط پیدا نشد</EmptyStateTitle>
            <EmptyStateDescription>فیلترها را کم کن یا نامِ تخصص را بنویس؛ مثلاً «قلب» به‌جای نامِ بیماری.</EmptyStateDescription>
            <EmptyStateActions>
              <Button variant="outline" onClick={reset}>
                پاک‌کردنِ فیلترها
              </Button>
            </EmptyStateActions>
          </EmptyState>
        ) : (
          <ul className="flex flex-col gap-3">
            {visible.map((doctor) => (
              <li key={doctor.id} className="flex flex-col gap-4 rounded-xl border border-border bg-card p-4 sm:flex-row sm:items-center sm:gap-5 sm:p-5">
                <div className="flex min-w-0 flex-1 gap-4">
                  <Avatar src={stockImages[doctor.photo].src} alt="" name={doctor.name} className="size-16 shrink-0 sm:size-20" />
                  <div className="flex min-w-0 flex-col gap-1.5">
                    <a href="#" className="leading-snug font-bold hover:underline">
                      {doctor.name}
                    </a>
                    <span className="text-sm text-muted-foreground">
                      {doctor.specialty} · ن.ن.پ {doctor.council}
                    </span>
                    <span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
                      <span className="flex items-center gap-1">
                        <StarIcon aria-hidden className="size-4 fill-[var(--dig-amber-400)] text-[var(--dig-amber-400)]" />
                        <span className="font-medium">{toFaDigits(String(doctor.rating).replace(".", "٫"))}</span>
                        <span className="text-muted-foreground">({toFaDigits(doctor.reviews)} نظر)</span>
                      </span>
                      {doctor.online && (
                        <Badge variant="soft" color="info">
                          <ChatIcon aria-hidden />
                          ویزیتِ آنلاین
                        </Badge>
                      )}
                    </span>
                    <span className="flex items-center gap-1 text-sm text-muted-foreground">
                      <LocationIcon aria-hidden className="size-4 shrink-0" />
                      <span className="truncate">{doctor.clinic}</span>
                      <span className="shrink-0">· {toFaDigits(String(doctor.km).replace(".", "٫"))} کیلومتر</span>
                    </span>
                  </div>
                </div>

                {/* ستونِ نوبت: در موبایل زیرِ اطلاعات با خطِ جداکننده، در دسکتاپ کنارش. */}
                <div className="flex items-center justify-between gap-3 border-t border-border pt-4 sm:w-52 sm:flex-col sm:items-stretch sm:border-t-0 sm:border-s sm:ps-5 sm:pt-0">
                  {doctor.next ? (
                    <span className="flex flex-col gap-0.5 text-sm">
                      <span className="text-muted-foreground">نزدیک‌ترین نوبت</span>
                      <span className={`flex items-center gap-1.5 font-semibold ${doctor.next.dayOffset === 0 ? "text-success" : ""}`}>
                        <CalendarIcon aria-hidden className="size-4" />
                        {dayLabel(doctor.next.dayOffset)}، {doctor.next.time}
                      </span>
                    </span>
                  ) : (
                    <span className="text-sm text-muted-foreground">نوبتِ خالی در این هفته نیست</span>
                  )}
                  <ControlSizeProvider size="lg" mobileSize="default" asChild>
                    {doctor.next ? <Button>دریافتِ نوبت</Button> : <Button variant="outline">نوبت باز شد خبرم کن</Button>}
                  </ControlSizeProvider>
                </div>
              </li>
            ))}
          </ul>
        )}
      </main>
    </ControlSizeProvider>
  )
}