احراز هویت
پیشنمایش تمامصفحهاحراز هویت سهمرحلهای: اطلاعات هویتی، مدارک و وضعیت بررسی.
۱. نصب کامپوننتها
این بلاک از ۸ آیتم دیگ استفاده میکند: 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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
)
}