کارت به کارت
پیشنمایش تمامصفحهانتقال وجه: حساب مبدأ، شماره کارت مقصد، مبلغ با کنترل موجودی، تأیید خلاصه و رمز پویا.
۱. نصب کامپوننتها
این بلاک از ۱۴ آیتم دیگ استفاده میکند: 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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
);
}