项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

356 行
14 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import React, { useState, useEffect, useRef } from 'react';
import { IcClose, IcDelete, IcWallet, IcBuilding, IcNavBack, IcCheck, IcNavForward, IcExpand, IcCollapse } from '../res/icons';
import { useAlipayGestures } from '../hooks/useAlipayGestures';
import { useAlipayStrings } from '../hooks/useAlipayStrings';
import { useLocale } from '@/apps/Alipay/locale';
import { localizeBankName } from '../utils/localizeBankName';
interface BankCardInfo {
id: string;
bankName: string;
last4: string;
bound: boolean;
available: number;
}
interface PaymentPasswordModalProps {
visible: boolean;
variant?: 'transfer' | 'cashier';
amount: string;
payeeName: string;
expectedPassword?: string;
onSuccess: (methodId: string) => void;
bankCards?: BankCardInfo[];
balance?: { total: number };
defaultMethodId?: string;
actionPrefix?: string;
maskedPhone?: string;
subject?: string;
}
export const PaymentPasswordModal: React.FC<PaymentPasswordModalProps> = ({
visible,
variant = 'transfer',
amount,
payeeName,
expectedPassword = '000000',
onSuccess,
bankCards = [],
balance,
defaultMethodId,
actionPrefix = 'transferPassword',
maskedPhone = '',
subject = '',
}) => {
const s = useAlipayStrings();
const { bindTap, bindBack } = useAlipayGestures();
const locale = useLocale();
const isEnglish = locale === 'en';
const [password, setPassword] = useState('');
const [error, setError] = useState(false);
const calledRef = useRef(false);
const [selectedMethodId, setSelectedMethodId] = useState<string>('balance');
const [expanded, setExpanded] = useState(false);
const boundCards = bankCards.filter(c => c.bound);
useEffect(() => {
if (visible) {
setPassword('');
setError(false);
calledRef.current = false;
setExpanded(false);
// 每次进入弹窗都重新选付款方式并输密码,避免沿用上次的银行卡导致「不用再选卡、像没走密码」的体验
setSelectedMethodId(defaultMethodId ?? 'balance');
} else {
setPassword('');
calledRef.current = false;
}
}, [visible, defaultMethodId]);
useEffect(() => {
if (!visible) return;
if (password.length === 6) {
if (calledRef.current) return;
if (password === expectedPassword) {
calledRef.current = true;
setTimeout(() => {
onSuccess(selectedMethodId);
}, 200);
} else {
setError(true);
setPassword('');
}
} else {
if (error && password.length > 0) {
setError(false);
}
}
}, [visible, password, expectedPassword, onSuccess, selectedMethodId, error]);
const handleNumberClick = (num: string) => {
if (password.length < 6) {
setPassword(prev => prev + num);
}
};
const handleDelete = () => {
setPassword(prev => prev.slice(0, -1));
};
const handleSelectMethod = (id: string) => {
setSelectedMethodId(id);
};
const handleConfirmExpanded = () => {
setExpanded(false);
};
if (!visible) return null;
const CheckIcon = ({ size = 16 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#1677FF" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
);
const Keypad = () => (
<div className="bg-[#D2D5DB] p-[5px] grid grid-cols-3 gap-[5px]">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
<button
key={num}
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: `${actionPrefix}.keypad.press` },
{ params: { digit: String(num) }, onTrigger: () => handleNumberClick(num.toString()) },
)}
className="bg-white rounded h-[52px] text-[22px] font-medium text-gray-900 active:bg-gray-200"
>
{num}
</button>
))}
<div />
<button
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: `${actionPrefix}.keypad.press` },
{ params: { digit: '0' }, onTrigger: () => handleNumberClick('0') },
)}
className="bg-white rounded h-[52px] text-[22px] font-medium text-gray-900 active:bg-gray-200"
>
0
</button>
<button
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: `${actionPrefix}.keypad.delete` },
{ onTrigger: handleDelete },
)}
className="bg-white rounded h-[52px] flex items-center justify-center text-gray-900 active:bg-gray-200"
>
<IcDelete size={22} />
</button>
</div>
);
const PasswordDots = () => (
<div className="flex gap-2 justify-center">
{[0, 1, 2, 3, 4, 5].map((index) => (
<div
key={index}
className={`w-11 h-11 rounded-lg border flex items-center justify-center bg-white ${
index === password.length && !error ? 'border-[#1677FF]' : 'border-gray-200'
}`}
>
{password.length > index && (
<div className="w-3 h-3 rounded-full bg-black" />
)}
</div>
))}
</div>
);
// ═══════════════════════════════════════════════════
// Transfer variant: bottom-sheet (original design)
// ═══════════════════════════════════════════════════
return (
<div className="fixed inset-0 z-[100] flex items-end justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div
className="w-full max-w-[430px] bg-app-bg rounded-t-xl overflow-hidden animate-in slide-in-from-bottom duration-300 flex flex-col max-h-[92%]"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
{/* Header */}
<div className="relative bg-app-surface border-b border-gray-100 p-4 text-center flex-shrink-0">
<button {...bindBack<HTMLButtonElement>({ stopPropagation: true })} className="absolute left-4 top-4 text-gray-400">
<IcClose size={24} />
</button>
<span className="text-lg font-medium text-gray-900">{s.enter_payment_password}</span>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="bg-app-surface px-6 pt-6 pb-4 flex flex-col items-center border-b border-gray-100">
<div className="text-sm text-gray-500 mb-2">{s.transfer_to_payee.replace('{payeeName}', payeeName)}</div>
<div className="text-4xl font-bold text-gray-900 mb-4">¥{amount}</div>
{/* Payment method selector — in-place expand/collapse */}
<div className="w-full bg-gray-50 rounded-lg overflow-hidden mb-4">
{/* Balance */}
<button
className="w-full flex items-center justify-between px-4 py-3 border-b border-gray-100"
onClick={() => handleSelectMethod('balance')}
>
<div className="flex items-center gap-2.5">
<IcWallet className="text-app-primary" size={18} />
<span className="text-gray-900 text-sm">{s.account_balance}</span>
</div>
{selectedMethodId === 'balance' && <CheckIcon size={16} />}
</button>
{expanded ? (
<>
{/* All bound bank cards */}
{boundCards.length > 0 && (
<>
<div className="px-4 pt-3 pb-1 text-xs text-gray-400">{s.payment_password_modal_bank_cards}</div>
{boundCards.map((card) => (
<button
key={card.id}
className="w-full flex items-center justify-between px-4 py-3 border-b border-gray-100"
onClick={() => handleSelectMethod(card.id)}
>
<div className="flex items-center gap-2.5">
<IcBuilding className="text-app-primary" size={18} />
<span className="text-gray-900 text-sm">{localizeBankName(card.bankName, isEnglish)} {s.payment_password_modal_debit_card} {isEnglish ? `(${card.last4})` : `${card.last4}`}</span>
</div>
{selectedMethodId === card.id && <CheckIcon size={16} />}
</button>
))}
</>
)}
{/* Credit */}
<div className="px-4 pt-3 pb-1 text-xs text-gray-400">{s.payment_password_modal_credit}</div>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-2.5">
<IcWallet className="text-app-primary" size={18} />
<div className="text-left">
<div className="text-gray-900 text-sm">{s.payment_password_modal_mybank_loan}</div>
<div className="text-xs text-gray-400">{s.payment_password_modal_available_after_open}</div>
</div>
</div>
</div>
{/* Unavailable */}
<div className="px-4 pt-3 pb-1 text-xs text-gray-400">{s.payment_password_modal_unavailable_methods}</div>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 opacity-50">
<div className="flex items-center gap-2.5">
<IcWallet className="text-gray-400" size={18} />
<span className="text-gray-400 text-sm">{s.huabei}</span>
</div>
</div>
{/* Collapse button */}
<button
className="w-full flex items-center justify-center py-2"
onClick={() => setExpanded(false)}
>
<IcCollapse className="text-gray-400" size={18} />
</button>
</>
) : (
<>
{/* First bound card (compact) */}
{boundCards.length > 0 && (
<button
className="w-full flex items-center justify-between px-4 py-3 border-b border-gray-100"
onClick={() => handleSelectMethod(boundCards[0].id)}
>
<div className="flex items-center gap-2.5">
<IcBuilding className="text-app-primary" size={18} />
<span className="text-gray-900 text-sm">{localizeBankName(boundCards[0].bankName, isEnglish)} {isEnglish ? `(${boundCards[0].last4})` : `${boundCards[0].last4}`}</span>
</div>
{selectedMethodId === boundCards[0].id && <CheckIcon size={16} />}
</button>
)}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-2.5">
<IcWallet className="text-gray-400" size={18} />
<span className="text-gray-500 text-sm">{s.payment_password_modal_add_bank_card}</span>
</div>
<IcNavForward className="text-gray-400" size={14} />
</div>
{/* Expand button */}
<button
className="w-full flex items-center justify-center py-2"
onClick={() => setExpanded(true)}
>
<IcExpand className="text-gray-400" size={18} />
</button>
</>
)}
</div>
{/* Error + password dots */}
<div className="h-6 mb-2">
{error && <div className="text-[#FF3B30] text-sm animate-pulse">{s.incorrect_payment_password}</div>}
</div>
<div className="flex gap-2 mb-4">
{[0, 1, 2, 3, 4, 5].map((index) => (
<div
key={index}
className="w-12 h-12 rounded-lg border border-gray-300 bg-app-surface flex items-center justify-center"
>
{password.length > index && (
<div className="w-3 h-3 rounded-full bg-black" />
)}
</div>
))}
</div>
{error && (
<button className="text-app-primary text-sm font-medium mt-2">{s.forgot_password}</button>
)}
</div>
</div>
{/* Keypad — always visible, pinned at bottom */}
<div className="bg-[#D2D5DB] p-1.5 grid grid-cols-3 gap-1.5 flex-shrink-0">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
<button
key={num}
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: `${actionPrefix}.keypad.press` },
{ params: { digit: String(num) }, onTrigger: () => handleNumberClick(num.toString()) },
)}
className="bg-app-surface rounded h-14 text-2xl font-medium text-gray-900 active:bg-gray-200 shadow-sm"
>
{num}
</button>
))}
<div className="bg-[#D2D5DB]"></div>
<button
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: `${actionPrefix}.keypad.press` },
{ params: { digit: '0' }, onTrigger: () => handleNumberClick('0') },
)}
className="bg-app-surface rounded h-14 text-2xl font-medium text-gray-900 active:bg-gray-200 shadow-sm"
>
0
</button>
<button
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: `${actionPrefix}.keypad.delete` },
{ onTrigger: handleDelete },
)}
className="bg-app-surface rounded h-14 flex items-center justify-center text-gray-900 active:bg-gray-200 shadow-sm"
>
<IcDelete size={24} />
</button>
</div>
</div>
</div>
);
};