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

تکمیل و پرداختِ نوبت

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

مراحلِ نوبت، برای خودم یا دیگری، کدِ ملی و موبایل با اعتبارسنجی، بیمهٔ پایه که سهمش از مبلغ کم می‌شود، علتِ مراجعه، روشِ پرداخت و قانونِ لغو؛ بعد از پرداخت رسید با کدِ رهگیری و افزودن به تقویم.

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

این بلاک از ۱۴ آیتم دیگ استفاده می‌کند: 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.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/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
yarn 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.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/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>
  )
}