تکمیل و پرداختِ نوبت
پیشنمایش تمامصفحهمراحلِ نوبت، برای خودم یا دیگری، کدِ ملی و موبایل با اعتبارسنجی، بیمهٔ پایه که سهمش از مبلغ کم میشود، علتِ مراجعه، روشِ پرداخت و قانونِ لغو؛ بعد از پرداخت رسید با کدِ رهگیری و افزودن به تقویم.
۱. نصب کامپوننتها
این بلاک از ۱۴ آیتم دیگ استفاده میکند: avatarbuttoncheckboxcontrol-sizeiconslabelpersianradio-cardradio-groupselectsteppertext-fieldtextareatoggle-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/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.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/select.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/textarea.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/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.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/select.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/textarea.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/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.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/select.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/textarea.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/checkbox.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.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/select.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/textarea.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
doctor-confirm.tsx
"use client"
import * as React from "react"
import { CalendarIcon, CheckIcon, CreditCardIcon, LocationIcon, WalletIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { Label } from "@/components/ui/label"
import { RadioCard } from "@/components/ui/radio-card"
import { RadioGroup } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Stepper, StepperItem, StepperTitle } from "@/components/ui/stepper"
import { TextField } from "@/components/ui/text-field"
import { Textarea } from "@/components/ui/textarea"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { formatCurrency, isValidMobile, isValidNationalId, toEnDigits, toFaDigits } from "@/lib/persian"
import { stockImages } from "@/lib/stock-images"
const FEE = 850_000
const INSURANCES = [
{ value: "none", label: "بدونِ بیمه", share: 0 },
{ value: "tamin", label: "تأمین اجتماعی", share: 0.3 },
{ value: "salamat", label: "بیمهٔ سلامت", share: 0.3 },
{ value: "army", label: "نیروهای مسلح", share: 0.3 },
]
/**
* تأیید و پرداختِ نوبت: مراحل (زمان ← اطلاعاتِ بیمار ← پرداخت)، نوبت برای خودم یا دیگری، کدِ ملی و موبایل با
* اعتبارسنجی بعد از ترکِ فیلد، بیمهٔ پایه که سهمش بیدرنگ از مبلغ کم میشود، علتِ مراجعه که پرکردنش لازم نیست، روشِ پرداخت و
* پذیرفتنِ قانونِ لغو. خلاصهٔ نوبت کنارِ فرم (در موبایل بالای آن) و بعد از پرداخت، همان کارت رسید با کدِ رهگیری
* و افزودن به تقویم میشود.
*/
export default function DoctorConfirmBlock() {
const [forWhom, setForWhom] = React.useState("me")
const [nationalId, setNationalId] = React.useState("0012345679")
const [mobile, setMobile] = React.useState("09121234567")
const [patient, setPatient] = React.useState("آرمیتا رستگار")
const [insurance, setInsurance] = React.useState("tamin")
const [method, setMethod] = React.useState("wallet")
const [agreed, setAgreed] = React.useState(false)
const [touched, setTouched] = React.useState<Record<string, boolean>>({})
const [submitted, setSubmitted] = React.useState(false)
const [paid, setPaid] = React.useState(false)
const share = (INSURANCES.find((item) => item.value === insurance)?.share ?? 0) * FEE
const total = FEE - share
const show = (field: string) => submitted || touched[field]
const errors = {
patient: !patient.trim() ? "نامِ بیمار را بنویس." : "",
nationalId: !isValidNationalId(toEnDigits(nationalId)) ? "کدِ ملی درست نیست؛ ۱۰ رقم است، بدونِ خط تیره." : "",
mobile: !isValidMobile(toEnDigits(mobile)) ? "موبایل را با ۰۹ شروع کن، ۱۱ رقم." : "",
agreed: !agreed ? "برای ادامه قانونِ لغو را بپذیر." : "",
}
const blur = (field: string) => () => setTouched((value) => ({ ...value, [field]: true }))
const summary = (
<section aria-label="خلاصهٔ نوبت" className="flex flex-col gap-4 rounded-xl border border-border bg-card p-5">
<div className="flex items-center gap-3">
<Avatar src={stockImages.doctorWomanCurly.src} alt="" name="سارا محمدی" className="size-12 shrink-0" />
<span className="flex min-w-0 flex-col">
<span className="truncate font-bold">دکتر سارا محمدی</span>
<span className="truncate text-sm text-muted-foreground">متخصصِ قلب و عروق</span>
</span>
</div>
<ul className="flex flex-col gap-2 text-sm [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground">
<li className="flex items-center gap-2">
<CalendarIcon aria-hidden />
یکشنبه ۲۶ مهر، ساعتِ ۱۶:۴۵
</li>
<li className="flex items-start gap-2">
<LocationIcon aria-hidden className="mt-0.5" />
مطبِ ونک، ساختمانِ پزشکانِ آرین، طبقهٔ ۴
</li>
</ul>
<dl className="flex flex-col gap-2 border-t border-border pt-4 text-sm">
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">ویزیتِ حضوری</dt>
<dd className="tabular-nums">{formatCurrency(FEE)}</dd>
</div>
{share > 0 && (
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">سهمِ بیمه</dt>
<dd className="text-success tabular-nums">−{formatCurrency(share)}</dd>
</div>
)}
<div className="flex items-baseline justify-between gap-2 border-t border-border pt-3">
<dt className="font-semibold">{paid ? "پرداختشده" : "قابلِ پرداخت"}</dt>
<dd className="text-lg font-bold tabular-nums">{formatCurrency(total)}</dd>
</div>
</dl>
</section>
)
if (paid) {
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto flex w-full max-w-lg flex-col gap-6 p-4 py-10 sm:p-6">
<div role="status" className="flex flex-col items-center gap-3 text-center">
<span aria-hidden className="flex size-14 items-center justify-center rounded-full bg-success-subtle text-success [&>svg]:size-7">
<CheckIcon />
</span>
<h1 className="text-2xl leading-snug font-bold">نوبتت قطعی شد</h1>
<p className="text-muted-foreground">
کدِ رهگیری <span className="font-bold text-foreground tabular-nums">۴۸۲۷۱۹</span> به موبایلت پیامک شد. ۱۵ دقیقه زودتر برسی، پذیرش راحتتر است.
</p>
</div>
{summary}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button variant="outline">
<CalendarIcon />
افزودن به تقویم
</Button>
<Button>نوبتهای من</Button>
</div>
</main>
</ControlSizeProvider>
)
}
return (
<ControlSizeProvider size="xl" asChild>
<form
noValidate
className="mx-auto grid w-full max-w-5xl grid-cols-1 items-start gap-6 p-4 sm:p-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-8"
onSubmit={(event) => {
event.preventDefault()
setSubmitted(true)
const first = Object.entries(errors).find(([, message]) => message)
if (first) {
document.getElementById(`dc-${first[0]}`)?.focus()
return
}
setPaid(true)
}}
>
<div className="flex min-w-0 flex-col gap-8">
<header className="flex flex-col gap-5">
<h1 className="text-2xl leading-snug font-bold">تکمیلِ نوبت</h1>
<Stepper value={1}>
<StepperItem>
<StepperTitle>زمان</StepperTitle>
</StepperItem>
<StepperItem>
<StepperTitle>اطلاعاتِ بیمار</StepperTitle>
</StepperItem>
<StepperItem>
<StepperTitle>پرداخت</StepperTitle>
</StepperItem>
</Stepper>
</header>
<div className="lg:hidden">{summary}</div>
<section aria-labelledby="dc-patient-title" className="flex flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 id="dc-patient-title" className="text-lg leading-snug font-bold">
اطلاعاتِ بیمار
</h2>
<ControlSizeProvider size="lg" mobileSize="default" asChild>
<ToggleGroup
type="single"
variant="segmented"
value={forWhom}
onValueChange={(value) => {
if (!value) return
setForWhom(value as string)
if (value === "other") {
setPatient("")
setNationalId("")
}
}}
aria-label="نوبت برای"
>
<ToggleGroupItem value="me">برای خودم</ToggleGroupItem>
<ToggleGroupItem value="other">برای دیگری</ToggleGroupItem>
</ToggleGroup>
</ControlSizeProvider>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<TextField id="dc-patient" label="نام و نامِ خانوادگیِ بیمار" value={patient} onValueChange={setPatient} onBlur={blur("patient")} invalid={show("patient") && !!errors.patient} errorMessage={errors.patient} autoComplete="name" className="sm:col-span-2" />
<TextField id="dc-nationalId" label="کدِ ملی" inputMode="numeric" faDigits maxLength={10} value={nationalId} onValueChange={setNationalId} onBlur={blur("nationalId")} invalid={show("nationalId") && !!errors.nationalId} errorMessage={errors.nationalId} autoComplete="off" />
<TextField id="dc-mobile" label="موبایل" type="tel" inputMode="numeric" faDigits maxLength={11} value={mobile} onValueChange={setMobile} onBlur={blur("mobile")} invalid={show("mobile") && !!errors.mobile} errorMessage={errors.mobile} description="پیامکِ یادآوری و کدِ رهگیری به این شماره میآید." autoComplete="tel" />
<div className="flex flex-col gap-2">
<Label id="dc-insurance-label">بیمهٔ پایه</Label>
<Select value={insurance} onValueChange={setInsurance}>
<SelectTrigger aria-labelledby="dc-insurance-label" className="w-full">
<SelectValue />
</SelectTrigger>
<ControlSizeProvider size="xl" asChild>
<SelectContent>
{INSURANCES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</ControlSizeProvider>
</Select>
<p className="text-xs text-muted-foreground">{share ? `٪${toFaDigits(30)} را بیمه میپردازد؛ دفترچه را همراه بیاور.` : "مبلغِ کامل را خودت میپردازی."}</p>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="dc-reason">علتِ مراجعه</Label>
<Textarea id="dc-reason" rows={3} placeholder="مثلاً تپشِ قلب هنگامِ ورزش از دو هفته پیش" aria-describedby="dc-reason-hint" />
<p id="dc-reason-hint" className="text-xs text-muted-foreground">
پرکردنش لازم نیست؛ پزشک پیش از ویزیت میخواندش.
</p>
</div>
</div>
</section>
<section aria-labelledby="dc-pay-title" className="flex flex-col gap-4">
<h2 id="dc-pay-title" className="text-lg leading-snug font-bold">
روشِ پرداخت
</h2>
<RadioGroup value={method} onValueChange={setMethod} aria-labelledby="dc-pay-title" className="gap-2">
<RadioCard
value="wallet"
title="کیف پول"
description={`موجودی ${formatCurrency(1_200_000)}`}
media={
<span className="flex size-10 items-center justify-center rounded-lg bg-muted [&>svg]:size-5">
<WalletIcon />
</span>
}
/>
<RadioCard
value="bank"
title="درگاهِ بانکی"
description="همهٔ کارتهای عضوِ شتاب"
media={
<span className="flex size-10 items-center justify-center rounded-lg bg-muted [&>svg]:size-5">
<CreditCardIcon />
</span>
}
/>
</RadioGroup>
</section>
<div className="flex flex-col gap-2">
<label className="flex cursor-pointer items-start gap-3 text-sm leading-6">
<Checkbox id="dc-agreed" checked={agreed} onCheckedChange={(value) => setAgreed(value === true)} aria-invalid={show("agreed") && !!errors.agreed} aria-describedby={show("agreed") && errors.agreed ? "dc-agreed-error" : undefined} className="mt-1" />
<span>
لغو تا ۲ ساعت پیش از نوبت رایگان است و مبلغ به کیف پول برمیگردد؛ بعد از آن ٪۵۰ کم میشود.{" "}
<a href="#" className="font-medium text-brand-secondary underline-offset-4 hover:underline">
قوانینِ لغو
</a>
</span>
</label>
{show("agreed") && errors.agreed && (
<p id="dc-agreed-error" className="text-sm text-destructive">
{errors.agreed}
</p>
)}
</div>
<Button type="submit" fullWidth>
پرداختِ {formatCurrency(total)} و ثبتِ نوبت
</Button>
</div>
<aside className="hidden lg:sticky lg:top-6 lg:block">{summary}</aside>
</form>
</ControlSizeProvider>
)
}