نوبتهای من
پیشنمایش تمامصفحهنوبتهای پیشِ رو و گذشته در زبانه؛ زمانِ نسبی، مکان یا تماسِ تلفنی، کدِ رهگیری، مسیریابی، تغییرِ زمان و لغو با دیالوگِ تأیید و توستِ برگردان؛ امتیاز به ویزیتِ گذشته و نوبتِ دوباره.
۱. نصب کامپوننتها
این بلاک از ۱۲ آیتم دیگ استفاده میکند: 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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
)
}