مدیریت کارتها
پیشنمایش تمامصفحهکارتهای بانکی با شمارهٔ پوشیده، کارت اصلی، مسدودسازی موقت و حذف با تأیید.
۱. نصب کامپوننتها
این بلاک از ۱۴ آیتم دیگ استفاده میکند: alert-dialogbadgebank-logobuttoncardcontrol-sizedialogdropdown-menuiconslabelpersianswitchtext-fieldtoast
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/alert-dialog.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/bank-logo.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/dropdown-menu.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/switch.json https://docs.digdesign.ir/r/text-field.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/badge.json https://docs.digdesign.ir/r/bank-logo.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/dropdown-menu.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/switch.json https://docs.digdesign.ir/r/text-field.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/badge.json https://docs.digdesign.ir/r/bank-logo.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/dropdown-menu.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/switch.json https://docs.digdesign.ir/r/text-field.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/badge.json https://docs.digdesign.ir/r/bank-logo.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/dropdown-menu.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/switch.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
card-management.tsx
"use client"
import * as React from "react"
import { EditIcon, StarIcon, MoreHorizontalIcon, TrashIcon } from "@/components/icons"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { BankLogo, getBankByCard } from "@/components/ui/bank-logo"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { TextField } from "@/components/ui/text-field"
import { toast } from "@/components/ui/toast"
import { toFaDigits } from "@/lib/persian"
type BankCard = { id: string; number: string; expiry: string; blocked: boolean; main: boolean }
const INITIAL: BankCard[] = [
{ id: "c1", number: "6104337812345678", expiry: "07/08", blocked: false, main: true },
{ id: "c2", number: "6219861098765432", expiry: "04/05", blocked: false, main: false },
{ id: "c3", number: "5022291011223344", expiry: "02/11", blocked: true, main: false },
]
/** شمارهٔ کارت: فقط ۶ رقمِ اول و ۴ رقمِ آخر؛ ارقام همیشه چپبهراست. */
function maskCard(number: string) {
return toFaDigits(`${number.slice(0, 4)} ${number.slice(4, 6)}** **** ${number.slice(-4)}`)
}
const bankName = (number: string) => getBankByCard(number)?.name ?? "کارت بانکی"
export default function CardManagementBlock() {
const [cards, setCards] = React.useState(INITIAL)
const [removing, setRemoving] = React.useState<BankCard | null>(null)
const [pinFor, setPinFor] = React.useState<BankCard | null>(null)
const [adding, setAdding] = React.useState(false)
const update = (id: string, patch: Partial<BankCard>) =>
setCards((list) => list.map((card) => (card.id === id ? { ...card, ...patch } : card)))
function setBlocked(card: BankCard, blocked: boolean) {
update(card.id, { blocked })
// مسدودکردن کارِ حساسی است: هشدار با امکانِ برگرداندن، نه یک «موفق شد» همشکلِ فعالکردن.
if (blocked) {
toast.warning("کارت مسدود شد.", {
description: `${bankName(card.number)} تا رفع مسدودی قابل استفاده نیست.`,
action: { label: "لغو", onClick: () => update(card.id, { blocked: false }) },
})
} else {
toast.success("کارت دوباره فعال شد.", { description: `${bankName(card.number)} آمادهٔ استفاده است.` })
}
}
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-4 sm:p-6">
<header className="mb-2 flex flex-wrap items-end justify-between gap-x-4 gap-y-8">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold">کارتهای من</h1>
<p className="text-sm text-muted-foreground">کارت اصلی، مسدودی و رمز کارتهایتان را اینجا مدیریت کنید.</p>
</div>
<Button variant="outline" onClick={() => setAdding(true)}>
افزودن کارت
</Button>
</header>
{cards.map((card) => (
<Card key={card.id}>
<CardHeader className="flex items-center gap-3">
<BankLogo cardNumber={card.number} size="lg" />
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<CardTitle>{bankName(card.number)}</CardTitle>
{card.main && (
<Badge variant="soft" color="primary">
کارت اصلی
</Badge>
)}
{card.blocked && (
<Badge variant="soft" color="destructive">
مسدود
</Badge>
)}
</div>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`عملیات ${bankName(card.number)}`}>
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={card.main}
onSelect={() => {
setCards((list) => list.map((item) => ({ ...item, main: item.id === card.id })))
toast.success("کارت اصلی عوض شد.", { description: bankName(card.number) })
}}
>
<StarIcon />
انتخاب بهعنوان کارت اصلی
</DropdownMenuItem>
<DropdownMenuItem disabled={card.blocked} onSelect={() => setPinFor(card)}>
<EditIcon />
تغییر رمز دوم
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => setRemoving(card)}>
<TrashIcon />
حذف کارت
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
</CardHeader>
<CardContent className="flex flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-1">
<bdi dir="ltr" className="text-lg font-semibold tracking-wider">
{maskCard(card.number)}
</bdi>
<span className="text-sm text-muted-foreground">
انقضا <bdi dir="ltr">{toFaDigits(card.expiry)}</bdi>
</span>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`block-${card.id}`}>مسدودسازی موقت</Label>
<Switch
id={`block-${card.id}`}
checked={card.blocked}
onCheckedChange={(blocked) => setBlocked(card, blocked)}
/>
</div>
</CardContent>
</Card>
))}
<AddCardDialog
open={adding}
onOpenChange={setAdding}
existing={cards.map((card) => card.number)}
onAdd={(number, expiry) => {
setCards((list) => [...list, { id: number, number, expiry, blocked: false, main: list.length === 0 }])
toast.success("کارت اضافه شد.", { description: bankName(number) })
}}
/>
<ChangePinDialog card={pinFor} onClose={() => setPinFor(null)} />
<AlertDialog open={removing !== null} onOpenChange={(open) => !open && setRemoving(null)}>
<ControlSizeProvider size="xl" asChild>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{removing && bankName(removing.number)} حذف شود؟</AlertDialogTitle>
<AlertDialogDescription>برای استفادهٔ دوباره باید کارت را از نو اضافه کنید.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>انصراف</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setCards((list) => list.filter((card) => card.id !== removing?.id))
toast("کارت حذف شد.")
}}
>
حذف کارت
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</ControlSizeProvider>
</AlertDialog>
</main>
</ControlSizeProvider>
)
}
function AddCardDialog({
open,
onOpenChange,
existing,
onAdd,
}: {
open: boolean
onOpenChange: (open: boolean) => void
existing: string[]
onAdd: (number: string, expiry: string) => void
}) {
const [number, setNumber] = React.useState("")
const [month, setMonth] = React.useState("")
const [year, setYear] = React.useState("")
const bank = getBankByCard(number)
const duplicate = existing.includes(number)
const monthValid = Number(month) >= 1 && Number(month) <= 12
const ready = number.length === 16 && Boolean(bank) && !duplicate && monthValid && year.length === 2
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<ControlSizeProvider size="xl" asChild>
<DialogContent>
<DialogHeader>
<DialogTitle>افزودن کارت</DialogTitle>
<DialogDescription>بانک از ۶ رقمِ اولِ کارت شناخته میشود.</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
if (!ready) return
onAdd(number, `${year}/${month.padStart(2, "0")}`)
setNumber("")
setMonth("")
setYear("")
onOpenChange(false)
}}
>
<TextField
label="شماره کارت"
inputMode="numeric"
dir="ltr"
digitGroups={[4, 4, 4, 4]}
autoComplete="cc-number"
value={number}
onValueChange={setNumber}
endContent={<BankLogo cardNumber={number} size="sm" showName />}
invalid={(number.length >= 6 && !bank) || duplicate}
errorMessage={duplicate ? "این کارت قبلاً اضافه شده است." : "این شماره کارت به هیچ بانکی تعلق ندارد."}
required
/>
<div className="grid grid-cols-2 gap-3">
<TextField
label="ماه انقضا"
inputMode="numeric"
dir="ltr"
maxLength={2}
placeholder="۰۸"
autoComplete="cc-exp-month"
value={month}
onValueChange={setMonth}
invalid={month.length === 2 && !monthValid}
errorMessage="بین ۱ تا ۱۲"
required
/>
<TextField
label="سال انقضا"
inputMode="numeric"
dir="ltr"
maxLength={2}
placeholder="۰۶"
autoComplete="cc-exp-year"
value={year}
onValueChange={setYear}
required
/>
</div>
<DialogFooter>
<Button type="submit" fullWidth disabled={!ready}>
افزودن
</Button>
</DialogFooter>
</form>
</DialogContent>
</ControlSizeProvider>
</Dialog>
)
}
function ChangePinDialog({ card, onClose }: { card: BankCard | null; onClose: () => void }) {
const [current, setCurrent] = React.useState("")
const [next, setNext] = React.useState("")
const [repeat, setRepeat] = React.useState("")
const [saving, setSaving] = React.useState(false)
const mismatch = repeat.length > 0 && repeat !== next
const ready = current.length >= 5 && next.length >= 5 && next === repeat
function reset() {
setCurrent("")
setNext("")
setRepeat("")
onClose()
}
return (
<Dialog open={card !== null} onOpenChange={(open) => !open && reset()}>
<ControlSizeProvider size="xl" asChild>
<DialogContent>
<DialogHeader>
<DialogTitle>تغییر رمز دوم</DialogTitle>
<DialogDescription>
{card && bankName(card.number)} · <bdi dir="ltr">{card && maskCard(card.number)}</bdi>
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={async (event) => {
event.preventDefault()
if (!ready) return
setSaving(true)
await new Promise((resolve) => setTimeout(resolve, 800))
setSaving(false)
toast.success("رمز دوم عوض شد.", { description: "از تراکنشِ بعد رمز تازه را وارد کنید." })
reset()
}}
>
<TextField
label="رمز دوم فعلی"
type="password"
inputMode="numeric"
dir="ltr"
maxLength={12}
autoComplete="off"
value={current}
onValueChange={setCurrent}
required
/>
<TextField
label="رمز دوم تازه"
type="password"
inputMode="numeric"
dir="ltr"
maxLength={12}
autoComplete="off"
description="۵ تا ۱۲ رقم"
value={next}
onValueChange={setNext}
required
/>
<TextField
label="تکرار رمز تازه"
type="password"
inputMode="numeric"
dir="ltr"
maxLength={12}
autoComplete="off"
value={repeat}
onValueChange={setRepeat}
invalid={mismatch}
errorMessage="با رمز تازه یکی نیست."
required
/>
<DialogFooter>
<Button type="submit" fullWidth loading={saving} disabled={!ready}>
ذخیره
</Button>
</DialogFooter>
</form>
</DialogContent>
</ControlSizeProvider>
</Dialog>
)
}