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

انتقال وجه: حساب مبدأ، شماره کارت مقصد، مبلغ با کنترل موجودی، تأیید خلاصه و رمز پویا.

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

این بلاک از ۱۴ آیتم دیگ استفاده می‌کند: alert-dialogavatarbank-logobuttoncontrol-sizedialogiconsinput-otplabelpersianselectseparatortext-fieldtoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/alert-dialog.json https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/bank-logo.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input-otp.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/separator.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/avatar.json https://docs.digdesign.ir/r/bank-logo.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input-otp.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/separator.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/avatar.json https://docs.digdesign.ir/r/bank-logo.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input-otp.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/separator.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/avatar.json https://docs.digdesign.ir/r/bank-logo.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input-otp.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/separator.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

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

money-transfer.tsx
"use client";

import * as React from "react";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { CheckIcon, CopyIcon } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
import { BankLogo, getBankByCard } from "@/components/ui/bank-logo";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { ControlSizeProvider } from "@/components/ui/control-size";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectItemDescription,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { TextField } from "@/components/ui/text-field";
import { toast } from "@/components/ui/toast";
import {
  formatCurrency,
  formatJalaliDateTime,
  formatNumber,
  toFaDigits,
} from "@/lib/persian";
import { stockImages, type StockImageName } from "@/lib/stock-images";

const ACCOUNTS = [
  {
    id: "current",
    label: "حساب جاری",
    bank: "mellat",
    number: "0123-4567-89",
    balance: 128_450_000,
  },
  {
    id: "saving",
    label: "حساب پس‌انداز",
    bank: "saman",
    number: "0987-6543-21",
    balance: 42_000_000,
  },
];

const QUICK_AMOUNTS = [500_000, 1_000_000, 5_000_000];
const RECENT: { name: string; card: string; image: StockImageName }[] = [
  { name: "سارا محمدی", card: "6037997512345678", image: "personWoman2" },
  { name: "امیر رضایی", card: "6219861034567812", image: "personMan1" },
  { name: "نگار احمدی", card: "6104337798765432", image: "personWoman3" },
];

type Receipt = {
  amount: number;
  card: string;
  owner: string;
  source: (typeof ACCOUNTS)[number];
  code: string;
  date: Date;
};

/** جای استعلامِ واقعی نامِ دارندهٔ کارت (سرویس بانک یا شاپرک). */
async function lookupCardOwner(card: string) {
  await new Promise((resolve) => setTimeout(resolve, 700));
  return card.endsWith("0000") ? null : "آرمیتا رستگار";
}

/** «۶۰۳۷ ۹۹۷۵ ۱۲۳۴ ۵۶۷۸» برای خلاصهٔ تأیید. */
const groupCard = (card: string) =>
  toFaDigits(card.replace(/(\d{4})(?=\d)/g, "$1 "));

/**
 * کارت به کارت: فرم با استعلامِ نامِ دارنده و لوگوی بانک، مبلغ‌های پرکاربرد، ستونِ کنار با
 * موجودی و مقصدهای اخیر (عکس‌دار، با یک کلیک پر می‌شود)؛ تأیید، رمزِ پویا و رسید.
 */
export default function MoneyTransferBlock() {
  const [account, setAccount] = React.useState("current");
  const [card, setCard] = React.useState("");
  const [amount, setAmount] = React.useState("");
  const [confirming, setConfirming] = React.useState(false);
  const [otpStep, setOtpStep] = React.useState(false);
  const [sending, setSending] = React.useState(false);
  const [receipt, setReceipt] = React.useState<Receipt | null>(null);
  const [copied, setCopied] = React.useState(false);
  const [owner, setOwner] = React.useState<{
    card: string;
    name: string | null;
  } | null>(null);

  const source = ACCOUNTS.find((item) => item.id === account)!;
  const value = Number(amount || 0);
  const overBalance = value > source.balance;
  const destinationBank = getBankByCard(card);
  const complete = card.length === 16 && Boolean(destinationBank);
  // نتیجهٔ استعلام فقط برای همان کارتی معتبر است که استعلام شده.
  const ownerName = owner?.card === card ? owner.name : undefined;
  const lookingUp = complete && owner?.card !== card;
  const ready = complete && Boolean(ownerName) && value > 0 && !overBalance;

  React.useEffect(() => {
    if (!complete) return;
    let current = true;
    lookupCardOwner(card).then((name) => current && setOwner({ card, name }));
    return () => {
      current = false;
    };
  }, [card, complete]);

  async function send(code: string) {
    setSending(true);
    // جای درخواستِ واقعی انتقال با کدِ رمزِ پویا.
    await new Promise((resolve) => setTimeout(resolve, 900));
    setSending(false);
    setOtpStep(false);
    // انتقالِ پول رسید می‌خواهد، نه یک اعلانِ چندثانیه‌ای: کاربر باید بتواند کد پیگیری را بردارد.
    setReceipt({
      amount: value,
      card,
      owner: ownerName ?? "",
      source,
      code: `${code}8821`,
      date: new Date(),
    });
    setCard("");
    setAmount("");
  }

  async function copyCode(code: string) {
    try {
      await navigator.clipboard.writeText(code);
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {
      toast.error("کپی نشد؛ کد را دستی بردارید.");
    }
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto grid grid-cols-1 w-full max-w-5xl items-start gap-8 p-4 sm:p-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
        <form
          className="flex flex-col gap-8 rounded-xl border border-border bg-card p-6 sm:p-8"
          onSubmit={(event) => {
            event.preventDefault();
            if (ready) setConfirming(true);
          }}
        >
          <div className="flex flex-col gap-1">
            <h1 className="text-2xl font-bold">کارت به کارت</h1>
            <p className="text-muted-foreground">
              انتقال وجه به هر کارتِ عضو شتاب.
            </p>
          </div>
          <div className="flex flex-col gap-5">
            <div className="flex flex-col gap-2">
              <Label id="source-label">از حساب</Label>
              <Select
                name="source"
                value={account}
                onValueChange={(next) => setAccount(next as string)}
              >
                <SelectTrigger
                  aria-labelledby="source-label"
                  className="w-full"
                >
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {ACCOUNTS.map((item) => (
                    // موجودی فقط در فهرست دیده می‌شود؛ دکمه فقط لوگو و نامِ حساب را نشان می‌دهد تا در موبایل بریده نشود.
                    <SelectItem key={item.id} value={item.id}>
                      <BankLogo bank={item.bank} size="sm" />
                      <span className="flex flex-col">
                        {item.label}
                        <SelectItemDescription>
                          موجودی {formatCurrency(item.balance)}
                        </SelectItemDescription>
                      </span>
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <TextField
              label="شماره کارت مقصد"
              name="card"
              inputMode="numeric"
              dir="ltr"
              digitGroups={[4, 4, 4, 4]}
              // کارتِ مقصد مالِ کسِ دیگری است؛ پرکردنش از کارت‌های ذخیره‌شدهٔ مرورگر اشتباه است.
              autoComplete="off"
              value={card}
              onValueChange={setCard}
              // لوگوی بانک با تایپِ ۶ رقمِ اول خودش ظاهر می‌شود.
              endContent={<BankLogo cardNumber={card} size="sm" showName />}
              invalid={
                (card.length >= 6 && !destinationBank) || ownerName === null
              }
              errorMessage={
                ownerName === null
                  ? "این کارت فعال نیست یا استعلام نشد."
                  : "این شماره کارت به هیچ بانکی تعلق ندارد."
              }
              description={
                lookingUp ? (
                  "در حال استعلام نام دارنده…"
                ) : ownerName ? (
                  <>
                    به نام{" "}
                    <span className="font-semibold text-foreground">
                      {ownerName}
                    </span>
                  </>
                ) : undefined
              }
              required
            />
            {/* مبلغ‌های پرکاربرد زیرِ خودِ فیلد، چون مالِ همان‌اند. */}
            <div className="flex flex-col gap-3">
              <TextField
                label="مبلغ"
                name="amount"
                inputMode="numeric"
                dir="rtl"
                groupDigits
                endContent="تومان"
                value={amount}
                onValueChange={setAmount}
                invalid={overBalance}
                errorMessage="مبلغ از موجودی حساب بیشتر است."
                description={`موجودی: ${formatCurrency(source.balance)}`}
                required
              />
              <div className="grid grid-cols-3 gap-2" role="group" aria-label="مبلغ‌های پرکاربرد (تومان)">
                {QUICK_AMOUNTS.map((quick) => (
                  <Button key={quick} type="button" variant="outline" className="px-2 tabular-nums" onClick={() => setAmount(String(quick))}>
                    {formatNumber(quick)}
                  </Button>
                ))}
              </div>
            </div>
            <TextField label="توضیحات" name="note" optional maxLength={60} />
          </div>
          <div className="border-t border-border pt-8">
            <Button type="submit" fullWidth disabled={!ready}>
              ادامه
            </Button>
          </div>
        </form>

        <aside className="flex flex-col gap-6">
          <div className="flex flex-col gap-1 rounded-xl bg-inverse p-6 text-[var(--dig-white)] [--muted-foreground:var(--dig-neutral-400)]">
            <span className="text-sm text-muted-foreground">
              موجودی {source.label}
            </span>
            <span className="text-2xl font-black tabular-nums">
              {formatCurrency(source.balance)}
            </span>
            <span className="mt-3 text-xs text-muted-foreground">
              سقفِ انتقالِ امروز: {formatCurrency(50_000_000)}
            </span>
          </div>
          <section
            aria-labelledby="recent-title"
            className="flex flex-col gap-3"
          >
            <h2 id="recent-title" className="font-semibold">
              مقصدهای اخیر
            </h2>
            <ul className="flex flex-col gap-2">
              {RECENT.map((item) => (
                <li key={item.card}>
                  <Button
                    type="button"
                    variant="outline"
                    onClick={() => setCard(item.card)}
                    className={`h-auto w-full justify-start gap-3 py-3 font-normal ${card === item.card ? "border-primary bg-primary-subtle" : ""}`}
                  >
                    <Avatar alt={item.name} src={stockImages[item.image].src} />
                    <span className="flex flex-1 flex-col items-start">
                      <span className="font-medium">{item.name}</span>
                      <bdi dir="ltr" className="text-xs text-muted-foreground">
                        {toFaDigits(
                          `${item.card.slice(0, 4)} •••• ${item.card.slice(-4)}`,
                        )}
                      </bdi>
                    </span>
                    <BankLogo cardNumber={item.card} size="sm" />
                  </Button>
                </li>
              ))}
            </ul>
          </section>
        </aside>

        <AlertDialog open={confirming} onOpenChange={setConfirming}>
          <ControlSizeProvider size="xl" asChild>
            <AlertDialogContent>
              <AlertDialogHeader>
                <AlertDialogTitle>انتقال را تأیید می‌کنید؟</AlertDialogTitle>
                <AlertDialogDescription>
                  این انتقال بعد از انجام قابل بازگشت نیست.
                </AlertDialogDescription>
              </AlertDialogHeader>
              <TransferParties
                source={source}
                card={card}
                owner={ownerName ?? ""}
              />
              <Separator />
              <div className="flex justify-between gap-2 text-sm font-semibold">
                <span>مبلغ</span>
                <span>{formatCurrency(value)}</span>
              </div>
              <AlertDialogFooter>
                <AlertDialogCancel>انصراف</AlertDialogCancel>
                <AlertDialogAction
                  onClick={() => {
                    setConfirming(false);
                    setOtpStep(true);
                  }}
                >
                  تأیید و دریافت رمز پویا
                </AlertDialogAction>
              </AlertDialogFooter>
            </AlertDialogContent>
          </ControlSizeProvider>
        </AlertDialog>

        <AlertDialog open={otpStep} onOpenChange={setOtpStep}>
          <ControlSizeProvider size="xl" asChild>
            <AlertDialogContent>
              <AlertDialogHeader>
                <AlertDialogTitle>رمز پویا</AlertDialogTitle>
                <AlertDialogDescription>
                  کد ۶ رقمی پیامک‌شده را وارد کنید.
                </AlertDialogDescription>
              </AlertDialogHeader>
              <InputOTP
                aria-label="رمز پویا"
                onComplete={send}
                disabled={sending}
                className="mx-auto"
              >
                <InputOTPGroup>
                  {[0, 1, 2, 3, 4, 5].map((index) => (
                    <InputOTPSlot key={index} index={index} />
                  ))}
                </InputOTPGroup>
              </InputOTP>
              <AlertDialogFooter>
                <AlertDialogCancel disabled={sending}>انصراف</AlertDialogCancel>
              </AlertDialogFooter>
            </AlertDialogContent>
          </ControlSizeProvider>
        </AlertDialog>

        <Dialog
          open={receipt !== null}
          onOpenChange={(open) => !open && setReceipt(null)}
        >
          {receipt && (
            <ControlSizeProvider size="xl" asChild>
              <DialogContent>
                <DialogHeader className="items-center text-center">
                  <span
                    aria-hidden
                    className="mb-1 flex size-12 items-center justify-center rounded-xl bg-success-subtle text-success [&_svg]:size-6"
                  >
                    <CheckIcon />
                  </span>
                  <DialogTitle>انتقال انجام شد</DialogTitle>
                  <DialogDescription>
                    {formatJalaliDateTime(receipt.date)}
                  </DialogDescription>
                </DialogHeader>
                <p className="text-center text-2xl font-bold">
                  {formatCurrency(receipt.amount)}
                </p>
                <TransferParties
                  source={receipt.source}
                  card={receipt.card}
                  owner={receipt.owner}
                />
                <Separator />
                <div className="flex items-center justify-between gap-2 text-sm">
                  <span className="text-muted-foreground">کد پیگیری</span>
                  <span className="flex items-center gap-1">
                    <bdi dir="ltr" className="font-semibold tracking-wider">
                      {toFaDigits(receipt.code)}
                    </bdi>
                    <Button
                      variant="ghost"
                      size="icon"
                      aria-label={copied ? "کد پیگیری کپی شد" : "کپی کد پیگیری"}
                      onClick={() => copyCode(receipt.code)}
                    >
                      {copied ? (
                        <CheckIcon className="text-success" />
                      ) : (
                        <CopyIcon />
                      )}
                    </Button>
                  </span>
                </div>
                <DialogFooter>
                  <Button fullWidth onClick={() => setReceipt(null)}>
                    بازگشت
                  </Button>
                </DialogFooter>
              </DialogContent>
            </ControlSizeProvider>
          )}
        </Dialog>
      </main>
    </ControlSizeProvider>
  );
}

/** دو طرفِ انتقال با لوگوی بانک؛ هم در تأیید و هم در رسید. */
function TransferParties({
  source,
  card,
  owner,
}: {
  source: (typeof ACCOUNTS)[number];
  card: string;
  owner: string;
}) {
  const bank = getBankByCard(card);
  return (
    <dl className="flex flex-col gap-3 text-sm">
      <div className="flex items-center justify-between gap-3">
        <dt className="text-muted-foreground">از</dt>
        <dd className="flex items-center gap-2">
          <span className="flex flex-col items-end">
            <span>{source.label}</span>
            <bdi dir="ltr" className="text-xs text-muted-foreground">
              {toFaDigits(source.number)}
            </bdi>
          </span>
          <BankLogo bank={source.bank} />
        </dd>
      </div>
      <div className="flex items-center justify-between gap-3">
        <dt className="text-muted-foreground">به</dt>
        <dd className="flex items-center gap-2">
          <span className="flex flex-col items-end">
            <span className="font-medium">{owner}</span>
            <span className="text-xs text-muted-foreground">
              {bank?.name} · <bdi dir="ltr">{groupCard(card)}</bdi>
            </span>
          </span>
          <BankLogo cardNumber={card} />
        </dd>
      </div>
    </dl>
  );
}