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

نوبت‌های پیشِ رو و گذشته در زبانه؛ زمانِ نسبی، مکان یا تماسِ تلفنی، کدِ رهگیری، مسیریابی، تغییرِ زمان و لغو با دیالوگِ تأیید و توستِ برگردان؛ امتیاز به ویزیتِ گذشته و نوبتِ دوباره.

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

این بلاک از ۱۲ آیتم دیگ استفاده می‌کند: alert-dialogavatarbadgebuttoncontrol-sizedropdown-menuempty-stateiconspersianratingtabstoast

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

۲. کد صفحه

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

doctor-account.tsx
"use client"

import * as React from "react"

import { CalendarIcon, ChatIcon, LocationIcon, MoreHorizontalIcon, PhoneIcon, RefreshIcon } from "@/components/icons"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog"
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateTitle } from "@/components/ui/empty-state"
import { Rating } from "@/components/ui/rating"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/components/ui/toast"
import { toFaDigits } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"

type Appointment = {
  id: string
  doctor: string
  specialty: string
  photo: StockImageName
  date: string
  time: string
  /** چند روزِ دیگر؛ برای «فردا»، «۳ روزِ دیگر». */
  inDays: number
  type: "clinic" | "phone"
  place: string
  code: string
}
type Past = { id: string; doctor: string; specialty: string; photo: StockImageName; date: string; rated?: number }

const UPCOMING: Appointment[] = [
  { id: "a1", doctor: "دکتر سارا محمدی", specialty: "قلب و عروق", photo: "doctorWomanCurly", date: "یکشنبه ۲۶ مهر", time: "۱۶:۴۵", inDays: 1, type: "clinic", place: "مطبِ ونک، ساختمانِ پزشکانِ آرین، طبقهٔ ۴", code: "۴۸۲۷۱۹" },
  { id: "a2", doctor: "دکتر رضا احمدی", specialty: "کودکان", photo: "doctorManScrubs", date: "چهارشنبه ۶ آبان", time: "۲۰:۰۰", inDays: 12, type: "phone", place: "مشاورهٔ تلفنی · پزشک با شما تماس می‌گیرد", code: "۵۱۳۰۴۲" },
]
const PAST: Past[] = [
  { id: "p1", doctor: "دکتر مریم کاظمی", specialty: "پوست و مو", photo: "doctorWomanRed", date: "۱۲ شهریور ۱۴۰۵" },
  { id: "p2", doctor: "دکتر علی رضایی", specialty: "پزشکِ عمومی", photo: "doctorManGlasses", date: "۳ مرداد ۱۴۰۵", rated: 5 },
]
const whenLabel = (days: number) => (days === 0 ? "امروز" : days === 1 ? "فردا" : `${toFaDigits(days)} روزِ دیگر`)

/**
 * نوبت‌های من: زبانه‌های «پیشِ رو» و «گذشته» با شمار. نوبتِ پیشِ رو با زمانِ نسبی (فردا، ۱۲ روزِ دیگر) برجسته،
 * نوعِ ویزیت، مکان، کدِ رهگیری و اقدامِ اصلی (مسیریابی یا آماده‌بودن برای تماس)؛ تغییرِ زمان و لغو در منو. لغو
 * دیالوگِ تأیید با پیامدِ مالی دارد و بعد از آن توستِ «برگردان». نوبتِ گذشته امتیازدادن با ستاره و «نوبتِ دوباره»
 * دارد. فهرستِ خالی راهِ گرفتنِ نوبت را نشان می‌دهد.
 */
export default function DoctorAccountBlock() {
  const [upcoming, setUpcoming] = React.useState(UPCOMING)
  const [past, setPast] = React.useState(PAST)
  const [cancelling, setCancelling] = React.useState<Appointment | null>(null)

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
        <header className="flex flex-wrap items-end justify-between gap-4">
          <div className="flex flex-col gap-1">
            <h1 className="text-2xl leading-snug font-bold">نوبت‌های من</h1>
            <p className="text-muted-foreground">یادآوری یک روز و دو ساعت پیش از هر نوبت پیامک می‌شود.</p>
          </div>
          <ControlSizeProvider size="lg" mobileSize="default" asChild>
            <Button variant="outline">
              <CalendarIcon />
              نوبتِ تازه
            </Button>
          </ControlSizeProvider>
        </header>

        <Tabs variant="line" defaultValue="upcoming" className="gap-6">
          <TabsList aria-label="نوبت‌ها">
            <TabsTrigger value="upcoming" className="flex-none">
              پیشِ رو <span className="text-muted-foreground tabular-nums">{toFaDigits(upcoming.length)}</span>
            </TabsTrigger>
            <TabsTrigger value="past" className="flex-none">
              گذشته <span className="text-muted-foreground tabular-nums">{toFaDigits(past.length)}</span>
            </TabsTrigger>
          </TabsList>

          <TabsContent value="upcoming">
            {upcoming.length === 0 ? (
              <EmptyState variant="bordered">
                <EmptyStateTitle>نوبتِ پیشِ رو نداری</EmptyStateTitle>
                <EmptyStateDescription>پزشکت را پیدا کن؛ بیشترِ پزشک‌ها همین هفته نوبتِ خالی دارند.</EmptyStateDescription>
                <EmptyStateActions>
                  <Button>جست‌وجوی پزشک</Button>
                </EmptyStateActions>
              </EmptyState>
            ) : (
              <ul className="flex flex-col gap-4">
                {upcoming.map((item) => (
                  <li key={item.id} className="flex flex-col overflow-hidden rounded-xl border border-border bg-card">
                    {/* سرِ کارت: زمانِ نسبی مهم‌ترین چیز است؛ نوبتِ نزدیک رنگِ اصلی می‌گیرد. */}
                    <div className={`flex flex-wrap items-center justify-between gap-2 px-5 py-3 ${item.inDays <= 1 ? "bg-primary-subtle" : "bg-subtle"}`}>
                      <span className="flex items-center gap-2 font-semibold [&>svg]:size-4">
                        <CalendarIcon aria-hidden />
                        {item.date}، ساعتِ {item.time}
                      </span>
                      <Badge variant="soft" color={item.inDays <= 1 ? "primary" : "default"}>
                        {whenLabel(item.inDays)}
                      </Badge>
                    </div>
                    <div className="flex flex-col gap-4 p-5">
                      <div className="flex items-start gap-3">
                        <Avatar src={stockImages[item.photo].src} alt="" name={item.doctor} className="size-12 shrink-0" />
                        <span className="flex min-w-0 flex-1 flex-col gap-1">
                          <span className="font-bold">{item.doctor}</span>
                          <span className="text-sm text-muted-foreground">{item.specialty}</span>
                          <span className="flex items-start gap-1.5 text-sm [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-muted-foreground">
                            {item.type === "clinic" ? <LocationIcon aria-hidden /> : <PhoneIcon aria-hidden />}
                            {item.place}
                          </span>
                        </span>
                        <DropdownMenu>
                          <ControlSizeProvider size="default" asChild>
                            <DropdownMenuTrigger asChild>
                              <Button variant="ghost" size="icon" aria-label={`گزینه‌های نوبتِ ${item.doctor}`} className="-me-2 shrink-0">
                                <MoreHorizontalIcon />
                              </Button>
                            </DropdownMenuTrigger>
                          </ControlSizeProvider>
                          <DropdownMenuContent align="end">
                            <DropdownMenuItem onSelect={() => toast("زمانِ تازه را انتخاب کن؛ نوبتِ فعلی تا تأییدِ زمانِ جدید می‌ماند.")}>
                              <RefreshIcon />
                              تغییرِ زمان
                            </DropdownMenuItem>
                            <DropdownMenuItem variant="destructive" onSelect={() => setCancelling(item)}>
                              لغوِ نوبت
                            </DropdownMenuItem>
                          </DropdownMenuContent>
                        </DropdownMenu>
                      </div>
                      <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
                        <span className="text-sm text-muted-foreground">
                          کدِ رهگیری <span className="font-semibold text-foreground tabular-nums">{item.code}</span>
                        </span>
                        <ControlSizeProvider size="lg" mobileSize="default" asChild>
                          {item.type === "clinic" ? (
                            <Button variant="outline">
                              <LocationIcon />
                              مسیریابی
                            </Button>
                          ) : (
                            <Button variant="outline">
                              <ChatIcon />
                              فرستادنِ آزمایش
                            </Button>
                          )}
                        </ControlSizeProvider>
                      </div>
                    </div>
                  </li>
                ))}
              </ul>
            )}
          </TabsContent>

          <TabsContent value="past">
            <ul className="flex flex-col divide-y divide-border rounded-xl border border-border bg-card">
              {past.map((item) => (
                <li key={item.id} className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center">
                  <span className="flex min-w-0 flex-1 items-center gap-3">
                    <Avatar src={stockImages[item.photo].src} alt="" name={item.doctor} className="size-12 shrink-0" />
                    <span className="flex min-w-0 flex-col gap-0.5">
                      <span className="font-bold">{item.doctor}</span>
                      <span className="text-sm text-muted-foreground">
                        {item.specialty} · {item.date}
                      </span>
                    </span>
                  </span>
                  <div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end">
                    {/* امتیاز همین‌جا با یک لمس؛ بعد از ثبت فقط‌خواندنی می‌شود. */}
                    <span className="flex flex-col gap-1">
                      <span className="text-xs text-muted-foreground">{item.rated ? "امتیازِ شما" : "به این ویزیت امتیاز بده"}</span>
                      <Rating
                        value={item.rated ?? 0}
                        readOnly={Boolean(item.rated)}
                        size="sm"
                        aria-label={`امتیاز به ${item.doctor}`}
                        onValueChange={(value) => {
                          setPast((list) => list.map((entry) => (entry.id === item.id ? { ...entry, rated: value } : entry)))
                          toast.success("ممنون؛ امتیازت ثبت شد.")
                        }}
                      />
                    </span>
                    <ControlSizeProvider size="lg" mobileSize="default" asChild>
                      <Button variant="outline">نوبتِ دوباره</Button>
                    </ControlSizeProvider>
                  </div>
                </li>
              ))}
            </ul>
          </TabsContent>
        </Tabs>

        <AlertDialog open={cancelling !== null} onOpenChange={(open) => !open && setCancelling(null)}>
          <ControlSizeProvider size="xl" asChild>
            <AlertDialogContent>
              <AlertDialogHeader>
                <AlertDialogTitle>نوبتِ {cancelling?.doctor} لغو شود؟</AlertDialogTitle>
                <AlertDialogDescription>
                  {cancelling && cancelling.inDays === 0 ? "کمتر از ۲ ساعت مانده؛ ٪۵۰ مبلغ کم می‌شود." : "لغو رایگان است و کلِ مبلغ تا چند دقیقه به کیف پولت برمی‌گردد."}
                </AlertDialogDescription>
              </AlertDialogHeader>
              <AlertDialogFooter>
                <AlertDialogCancel>نگه‌داشتنِ نوبت</AlertDialogCancel>
                <AlertDialogAction
                  variant="destructive"
                  onClick={() => {
                    if (!cancelling) return
                    const item = cancelling
                    const index = upcoming.findIndex((entry) => entry.id === item.id)
                    setUpcoming((list) => list.filter((entry) => entry.id !== item.id))
                    // تا وقتی توست باز است، نوبت هنوز برای کاربر نگه داشته شده و برمی‌گردد.
                    toast.success("نوبت لغو شد.", {
                      description: `${item.doctor}، ${item.date}`,
                      duration: 6000,
                      action: { label: "برگردان", onClick: () => setUpcoming((list) => (list.some((entry) => entry.id === item.id) ? list : [...list.slice(0, index), item, ...list.slice(index)])) },
                    })
                  }}
                >
                  لغوِ نوبت
                </AlertDialogAction>
              </AlertDialogFooter>
            </AlertDialogContent>
          </ControlSizeProvider>
        </AlertDialog>
      </main>
    </ControlSizeProvider>
  )
}