بلاک‌ها · حساب کاربری

احراز هویت سه‌مرحله‌ای: اطلاعات هویتی، مدارک و وضعیت بررسی.

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

این بلاک از ۸ آیتم دیگ استفاده می‌کند: buttoncontrol-sizedate-pickerfile-uploadiconspersiansteppertext-field

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/date-picker.json https://docs.digdesign.ir/r/file-upload.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/date-picker.json https://docs.digdesign.ir/r/file-upload.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/date-picker.json https://docs.digdesign.ir/r/file-upload.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/date-picker.json https://docs.digdesign.ir/r/file-upload.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/stepper.json https://docs.digdesign.ir/r/text-field.json

۲. کد صفحه

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

identity-verification.tsx
"use client"

import * as React from "react"

import { BoltIcon, CheckIcon, ClockIcon, CreditCardIcon, ShieldCheckIcon, WalletIcon, XIcon } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { DatePicker } from "@/components/ui/date-picker"
import { FileUpload } from "@/components/ui/file-upload"
import { Stepper, StepperItem, StepperTitle } from "@/components/ui/stepper"
import { TextField } from "@/components/ui/text-field"
import { isValidNationalId } from "@/lib/persian"

const STEPS = [
  { title: "اطلاعات هویتی", hint: "همان‌طور که روی کارتِ ملی آمده بنویس." },
  { title: "مدارک", hint: "از روی و پشتِ کارتِ ملی عکسِ واضح بگیر." },
  { title: "بررسی", hint: "" },
]
const BENEFITS = [
  { icon: <BoltIcon />, text: "برداشتِ آنی" },
  { icon: <CreditCardIcon />, text: "وام و خریدِ قسطی" },
  { icon: <WalletIcon />, text: "کیفِ پولِ بی‌سقف" },
]
const PHOTO_TIPS = [
  { ok: true, text: "هر چهار گوشهٔ کارت پیدا باشد" },
  { ok: true, text: "نورِ کافی، بدونِ فلاش" },
  { ok: false, text: "عکسِ تار یا بریده" },
  { ok: false, text: "اسکرین‌شات یا کپی کارت" },
]
const REVIEW = [
  { title: "مدارک رسید", text: "همین حالا", state: "done" },
  { title: "در حالِ بررسی", text: "معمولاً کمتر از ۲ ساعت، حداکثر ۲۴ ساعت", state: "current" },
  { title: "نتیجه پیامک می‌شود", text: "سقفِ تازه همان لحظه فعال می‌شود", state: "next" },
] as const

const YEARS_AGO = (years: number) => {
  const date = new Date()
  date.setFullYear(date.getFullYear() - years)
  date.setHours(0, 0, 0, 0)
  return date
}

/**
 * پیش‌نمایشِ تزئینیِ کارتِ ملی داخلِ قابِ اسکن، با خطِ اسکن و برچسبِ «تأییدشده»؛ فقط در
 * صفحهٔ پهن. با div ساخته شده تا با رنگِ برند عوض شود.
 */
function IdCardArt() {
  const corner = "absolute size-5 border-[var(--dig-white)]"
  return (
    <div aria-hidden className="relative hidden h-44 w-72 shrink-0 lg:block">
      {/* قابِ اسکن: چهار گوشه */}
      <span className={`${corner} start-0 top-0 rounded-ss-lg border-s-2 border-t-2`} />
      <span className={`${corner} end-0 top-0 rounded-se-lg border-e-2 border-t-2`} />
      <span className={`${corner} start-0 bottom-0 rounded-es-lg border-s-2 border-b-2`} />
      <span className={`${corner} end-0 bottom-0 rounded-ee-lg border-e-2 border-b-2`} />

      <div className="absolute inset-4 flex flex-col gap-2.5 rounded-xl bg-[var(--dig-white)] p-3.5 text-[var(--dig-slate-900)] shadow-card">
        <div className="flex items-center gap-2">
          <span className="size-5 rounded-full bg-[linear-gradient(135deg,var(--dig-violet-500),var(--dig-indigo-600))]" />
          <span className="text-[0.625rem] font-black">کارتِ ملیِ هوشمند</span>
          <span className="ms-auto h-1.5 w-8 rounded-full bg-[var(--dig-slate-200)]" />
        </div>
        <div className="flex flex-1 gap-3">
          <span className="flex w-14 items-end justify-center overflow-hidden rounded-lg bg-[linear-gradient(180deg,var(--dig-violet-100),var(--dig-violet-200))] text-[var(--dig-violet-500)]">
            <svg viewBox="0 0 40 44" className="w-11" fill="currentColor">
              <circle cx="20" cy="15" r="8" />
              <path d="M4 44c0-9 7-15 16-15s16 6 16 15z" />
            </svg>
          </span>
          <span className="flex flex-1 flex-col justify-between">
            <span className="flex flex-col gap-1">
              <span className="text-[0.5rem] text-[var(--dig-slate-400)]">نام و نام خانوادگی</span>
              <span className="h-2 w-4/5 rounded-full bg-[var(--dig-slate-200)]" />
            </span>
            <span className="flex flex-col gap-1">
              <span className="text-[0.5rem] text-[var(--dig-slate-400)]">کدِ ملی</span>
              <bdi dir="ltr" className="font-mono text-xs font-bold tracking-widest text-[var(--dig-slate-700)]">
                ۰۰۱۲۳۴۵۶۷۸
              </bdi>
            </span>
          </span>
        </div>
      </div>

      {/* خطِ اسکن روی کارت */}
      <span className="absolute inset-x-2 top-1/2 h-0.5 rounded-full bg-[var(--dig-green-400)] shadow-[0_0_12px_var(--dig-green-400)]" />

      <span className="absolute -bottom-3 start-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full bg-[var(--dig-green-500)] px-3 py-1 text-xs font-bold text-[var(--dig-white)] shadow-card rtl:translate-x-1/2 [&>svg]:size-3.5">
        <CheckIcon />
        تأییدشده
      </span>
    </div>
  )
}

/**
 * احراز هویت (KYC) در سه قدم، در یک ستونِ متمرکز. بنرِ بالا «چرا» را با عددِ واقعی می‌گوید
 * (سقفِ انتقال از ۱۰ به ۲۰۰ میلیون) و زمان و یک‌باره‌بودن را؛ هر قدم توضیحِ یک‌خطی دارد،
 * دکمهٔ «قبلی» فقط وقتی معنا دارد دیده می‌شود، کنارِ دکمهٔ اصلی پیامِ حریمِ خصوصی است و
 * قدمِ آخر به‌جای یک ساعتِ تنها، مسیرِ بررسی را با زمانِ هر مرحله نشان می‌دهد.
 */
export default function IdentityVerificationBlock() {
  const [step, setStep] = React.useState(0)
  const [birth, setBirth] = React.useState<Date>()
  const [front, setFront] = React.useState<File[]>([])
  const [back, setBack] = React.useState<File[]>([])
  const documentsReady = front.length === 1 && back.length === 1

  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="relative isolate flex items-center justify-between gap-8 overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--dig-violet-700),var(--dig-indigo-800))] p-5 text-[var(--dig-white)] sm:p-8">
          <span aria-hidden className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_90%_0%,var(--dig-violet-400),transparent_45%),radial-gradient(circle_at_0%_100%,var(--dig-blue-500),transparent_40%)]" />
          <span aria-hidden className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(color-mix(in_oklab,var(--dig-white)_30%,transparent)_1.2px,transparent_1.3px)] [background-size:18px_18px] [mask-image:linear-gradient(to_left,black,transparent_60%)]" />

          <div className="flex min-w-0 flex-col gap-4">
            <span className="flex w-fit items-center gap-1.5 rounded-full bg-[var(--dig-white)] px-3 py-1 text-xs font-bold text-[var(--dig-violet-700)] [&>svg]:size-3.5">
              <ClockIcon aria-hidden />
              ۳ دقیقه، فقط یک بار
            </span>
            <div className="flex flex-col gap-1.5">
              <h1 className="text-2xl font-black sm:text-3xl">احراز هویت</h1>
              <p className="text-sm leading-6 text-[var(--dig-violet-100)] sm:text-base">
                سقفِ انتقالِ روزانه از <strong className="font-bold text-[var(--dig-white)]">۱۰</strong> به{" "}
                <strong className="font-bold text-[var(--dig-white)]">۲۰۰ میلیون تومان</strong> می‌رسد.
              </p>
            </div>
            <ul className="flex flex-wrap gap-2">
              {BENEFITS.map((item) => (
                <li key={item.text} className="flex items-center gap-1.5 rounded-full bg-[color-mix(in_oklab,var(--dig-white)_14%,var(--dig-violet-800))] px-3 py-1.5 text-xs font-medium whitespace-nowrap">
                  <span aria-hidden className="flex [&_svg]:size-3.5">
                    {item.icon}
                  </span>
                  {item.text}
                </li>
              ))}
            </ul>
          </div>
          <IdCardArt />
        </header>

        <section aria-labelledby="kyc-step-title" className="flex flex-col gap-8 rounded-xl border border-border bg-card p-5 sm:p-8">
          <Stepper value={step}>
            {STEPS.map((item) => (
              <StepperItem key={item.title}>
                <StepperTitle>{item.title}</StepperTitle>
              </StepperItem>
            ))}
          </Stepper>

          {step < 2 && (
            <div className="flex flex-col gap-1">
              <h2 id="kyc-step-title" className="text-lg font-bold">
                {STEPS[step].title}
              </h2>
              <p className="text-sm text-muted-foreground">{STEPS[step].hint}</p>
            </div>
          )}

          <form
            className="flex flex-col gap-8"
            onSubmit={(event) => {
              event.preventDefault()
              setStep((value) => Math.min(value + 1, STEPS.length - 1))
            }}
          >
            {step === 0 && (
              <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
                <TextField label="نام و نام خانوادگی" name="fullName" autoComplete="name" required className="sm:col-span-2" />
                <TextField
                  label="کد ملی"
                  name="nationalId"
                  inputMode="numeric"
                  dir="ltr"
                  maxLength={10}
                  required
                  description="۱۰ رقم، بدونِ خط تیره."
                  validate={(value) => (isValidNationalId(value) ? undefined : value.length === 10 ? "کد ملی معتبر نیست." : "کد ملی باید ۱۰ رقم باشد.")}
                />
                <DatePicker
                  id="birth"
                  label="تاریخ تولد"
                  calendar="jalali"
                  value={birth}
                  onChange={setBirth}
                  minDate={YEARS_AGO(100)}
                  maxDate={YEARS_AGO(18)}
                  description="برای افرادِ بالای ۱۸ سال."
                  required
                  className="w-full"
                />
              </div>
            )}

            {step === 1 && (
              <div className="flex flex-col gap-6">
                {/* دو جای جدا به‌جای یک کادرِ «دو فایل»: کاربر می‌بیند کدام مانده و چرا دکمه غیرفعال است. */}
                <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                  <FileUpload name="nationalCardFront" accept="image/*" maxSize={5 * 1024 * 1024} files={front} onFilesChange={setFront} title="روی کارت ملی" hint="JPG یا PNG تا ۵ مگابایت" />
                  <FileUpload name="nationalCardBack" accept="image/*" maxSize={5 * 1024 * 1024} files={back} onFilesChange={setBack} title="پشت کارت ملی" hint="JPG یا PNG تا ۵ مگابایت" />
                </div>
                <ul aria-label="راهنمای عکس" className="grid grid-cols-1 gap-3 rounded-lg bg-subtle p-4 text-sm sm:grid-cols-2">
                  {PHOTO_TIPS.map((tip) => (
                    <li key={tip.text} className="flex items-center gap-2">
                      {tip.ok ? <CheckIcon aria-label="درست" className="size-4 shrink-0 text-success" /> : <XIcon aria-label="نادرست" className="size-4 shrink-0 text-destructive" />}
                      {tip.text}
                    </li>
                  ))}
                </ul>
              </div>
            )}

            {step === 2 && (
              <div className="flex flex-col gap-6" aria-live="polite">
                <div className="flex flex-col gap-1">
                  <h2 id="kyc-step-title" className="text-lg font-bold">
                    مدارک رسید
                  </h2>
                  <p className="text-sm text-muted-foreground">تا آن موقع می‌توانی با سقفِ فعلی کار کنی.</p>
                </div>
                {/* مسیرِ بررسی با زمانِ هر مرحله، تا کاربر بداند الان کجاست و چقدر مانده. */}
                <ol className="flex flex-col">
                  {REVIEW.map((item, index) => (
                    <li key={item.title} className="relative flex gap-4 pb-6 last:pb-0">
                      {index < REVIEW.length - 1 && <span aria-hidden className="absolute start-4 top-9 bottom-1 w-px bg-border" />}
                      <span
                        aria-hidden
                        className={`flex size-8 shrink-0 items-center justify-center rounded-full [&>svg]:size-4 ${
                          item.state === "done" ? "bg-success text-[var(--dig-white)]" : item.state === "current" ? "bg-[var(--dig-blue-50)] text-[var(--dig-blue-600)] ring-2 ring-[var(--dig-blue-500)]" : "border border-border text-muted-foreground"
                        }`}
                      >
                        {item.state === "done" ? <CheckIcon /> : item.state === "current" ? <ClockIcon /> : <ShieldCheckIcon />}
                      </span>
                      <span className="flex flex-col gap-0.5 pt-1">
                        <span className={item.state === "next" ? "font-medium text-muted-foreground" : "font-semibold"}>{item.title}</span>
                        <span className="text-sm text-muted-foreground">{item.text}</span>
                      </span>
                    </li>
                  ))}
                </ol>
                <Button variant="outline" className="self-start">
                  بازگشت به داشبورد
                </Button>
              </div>
            )}

            {step < 2 && (
              <div className="flex flex-col gap-4 border-t border-border pt-8">
                <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
                  {/* «قبلی» در قدمِ اول معنا ندارد؛ دکمهٔ غیرفعال فقط جا می‌گیرد. */}
                  {step > 0 ? (
                    <Button type="button" variant="ghost" onClick={() => setStep((value) => value - 1)}>
                      قبلی
                    </Button>
                  ) : (
                    <span className="hidden sm:block" />
                  )}
                  <Button type="submit" disabled={step === 1 && !documentsReady} className="w-full sm:w-auto sm:min-w-40">
                    {step === 1 ? "ارسال برای بررسی" : "ادامه"}
                  </Button>
                </div>
                <p className="flex items-center gap-2 text-xs text-muted-foreground [&>svg]:size-4 [&>svg]:shrink-0">
                  <ShieldCheckIcon aria-hidden />
                  اطلاعاتت رمزگذاری‌شده نگه‌داری می‌شود و فقط برای احرازِ هویت به کار می‌رود.
                </p>
              </div>
            )}
          </form>
        </section>
      </main>
    </ControlSizeProvider>
  )
}