بلاک‌ها · بانکی و پرداخت

مدیریت کارت‌ها

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

کارت‌های بانکی با شمارهٔ پوشیده، کارت اصلی، مسدودسازی موقت و حذف با تأیید.

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

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