"use client"; import { useEffect, useId, useRef, useState, type CSSProperties } from "react"; import "./card-wallet.css"; export type WalletCard = { id: string; nickname: string; holder: string; last4: string; expires: string; /** Face colour. */ color: "graphite" | "ink" | "plum" | "moss" | "sand"; /** Monthly limit and spend so far, in minor units. */ limit: number; spent: number; frozen?: boolean; }; export type CardSecrets = { number: string; cvc: string }; export type CardWalletProps = { cards: WalletCard[]; /** Fetch the full number and CVC from your server, after any step-up check. */ reveal: (id: string) => Promise; /** Persist a freeze change; reject to roll it back. */ onFreeze: (id: string, frozen: boolean) => Promise; currency?: string; locale?: string; /** Seconds before revealed details hide again. */ revealFor?: number; theme?: "dark" | "light"; className?: string; }; const groups = (n: string) => n.replace(/\D/g, "").replace(/(.{4})/g, "$1 ").trim(); /** * Card Wallet * Virtual cards as a small physical stack: the chosen card sits in front, * the rest peek behind it. Its details stay masked until asked for, then * hide themselves again on a visible countdown; a frozen card is hatched. */ export function CardWallet({ cards, reveal, onFreeze, currency = "USD", locale, revealFor = 30, theme = "dark", className = "" }: CardWalletProps) { const id = useId(); const [selected, setSelected] = useState(cards[0]?.id); const [frozen, setFrozen] = useState>(() => Object.fromEntries(cards.map((c) => [c.id, !!c.frozen]))); const [freezeBusy, setFreezeBusy] = useState(false); const [freezeError, setFreezeError] = useState(""); const [secrets, setSecrets] = useState<{ id: string; data: CardSecrets; until: number } | null>(null); const [revealing, setRevealing] = useState(false); const [revealError, setRevealError] = useState(""); const [left, setLeft] = useState(0); const [copied, setCopied] = useState(""); const timer = useRef | undefined>(undefined); const money = new Intl.NumberFormat(locale, { style: "currency", currency }); const card = cards.find((c) => c.id === selected) ?? cards[0]; const order = [card, ...cards.filter((c) => c.id !== card.id)]; // The countdown that hides revealed details again. useEffect(() => { if (!secrets) return; const tick = () => { const remaining = Math.max(0, Math.ceil((secrets.until - Date.now()) / 1000)); setLeft(remaining); if (!remaining) setSecrets(null); }; tick(); const t = setInterval(tick, 250); return () => clearInterval(t); }, [secrets]); useEffect(() => { setSecrets(null); setRevealError(""); setFreezeError(""); }, [selected]); useEffect(() => () => clearTimeout(timer.current), []); const doReveal = async () => { if (revealing) return; setRevealing(true); setRevealError(""); const target = card.id; try { const data = await reveal(target); setSecrets({ id: target, data, until: Date.now() + revealFor * 1000 }); } catch { setRevealError("The card details couldn't be shown. Try again."); } finally { setRevealing(false); } }; const toggleFreeze = async () => { if (freezeBusy) return; const target = card.id, next = !frozen[target]; setFrozen((f) => ({ ...f, [target]: next })); setFreezeBusy(true); setFreezeError(""); try { await onFreeze(target, next); } catch { setFrozen((f) => ({ ...f, [target]: !next })); setFreezeError(next ? "The card couldn't be frozen. It's still active." : "The card couldn't be unfrozen. It's still frozen."); } finally { setFreezeBusy(false); } }; const copy = async (label: string, value: string) => { clearTimeout(timer.current); try { await navigator.clipboard.writeText(value); setCopied(label); } catch { setCopied(`fail:${label}`); } timer.current = setTimeout(() => setCopied(""), 1800); }; const shown = secrets?.id === card.id ? secrets.data : null; const isFrozen = frozen[card.id]; const used = Math.min(1, card.spent / Math.max(1, card.limit)); return (
Choose a card {cards.map((c) => ( ))}

{card.nickname}

{card.holder}

{money.format(card.spent / 100)} of {money.format(card.limit / 100)} this month

0.85 || undefined} />
Card number
{shown ? groups(shown.number) : `•••• •••• •••• ${card.last4}`} {shown && }
Expires
{card.expires}{shown && }
CVC
{shown ? shown.cvc : "•••"}{shown && }
{shown ? ( ) : ( )}

{revealError || freezeError || (shown ? `Details hide in ${left}s.` : isFrozen ? "Frozen cards decline every purchase until unfrozen." : "")}

); }