"use client"; import { useEffect, useId, useRef, useState } from "react"; import { addBusinessDays } from "./business-days"; import "./transfer-composer.css"; export type TransferAccount = { id: string; name: string; last4: string; balance: number }; export type TransferRecipient = { id: string; name: string; bank: string; last4: string }; export type TransferMethod = { id: string; label: string; /** Business days to arrive, fastest and slowest. */ days: [number, number]; fee: number }; export type Transfer = { from: string; to: string; method: string; amount: number; fee: number; memo: string; arrives: [string, string] }; export type TransferComposerProps = { accounts: TransferAccount[]; recipients: TransferRecipient[]; methods?: TransferMethod[]; /** Submit the transfer; resolve with your reference, reject to stay on review with the reason. */ onSend: (transfer: Transfer) => Promise<{ reference: string }>; /** Today's date (YYYY-MM-DD) in the bank's time zone. */ today: string; /** Non-business days, 0 = Sunday. */ weekend?: number[]; currency?: string; locale?: string; theme?: "dark" | "light"; className?: string; }; const DEFAULT_METHODS: TransferMethod[] = [ { id: "ach", label: "ACH", days: [1, 3], fee: 0 }, { id: "wire", label: "Wire", days: [0, 0], fee: 2_500 }, ]; /** * Transfer Composer * Send money in three honest steps: the amount and where it goes, a review * that says what it costs and when it lands, then a receipt with a reference. */ export function TransferComposer({ accounts, recipients, methods = DEFAULT_METHODS, onSend, today, weekend = [0, 6], currency = "USD", locale, theme = "dark", className = "" }: TransferComposerProps) { const id = useId(); const [step, setStep] = useState<"compose" | "review" | "sent">("compose"); const [from, setFrom] = useState(accounts[0]?.id); const [to, setTo] = useState(recipients[0]?.id); const [method, setMethod] = useState(methods[0]?.id); const [raw, setRaw] = useState(""); const [memo, setMemo] = useState(""); const [sending, setSending] = useState(false); const [error, setError] = useState(""); const [reference, setReference] = useState(""); const heading = useRef(null); const amountRef = useRef(null); const shownStep = useRef(step); const money = new Intl.NumberFormat(locale, { style: "currency", currency }); const plain = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }); const day = new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }); // Move focus to the new step's heading only when the step really changes (not on mount). useEffect(() => { if (shownStep.current !== step) { shownStep.current = step; heading.current?.focus(); } }, [step]); const account = accounts.find((a) => a.id === from)!; const recipient = recipients.find((r) => r.id === to)!; const m = methods.find((x) => x.id === method)!; const amount = Math.round(Number(raw.replace(/[^\d.]/g, "") || 0) * 100); const total = amount + m.fee; const arrives: [string, string] = [addBusinessDays(today, m.days[0], weekend), addBusinessDays(today, m.days[1], weekend)]; const when = arrives[0] === arrives[1] ? (arrives[0] === today ? "today" : day.format(new Date(`${arrives[0]}T12:00:00Z`))) : `${day.format(new Date(`${arrives[0]}T12:00:00Z`))} – ${day.format(new Date(`${arrives[1]}T12:00:00Z`))}`; const problem = !amount ? "" : total > account.balance ? `That's more than the ${money.format(account.balance / 100)} available${m.fee ? " after the fee" : ""}.` : ""; const ready = amount > 0 && !problem; const typed = (v: string) => { // Keep digits and one decimal point, two places at most. const clean = v.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").replace(/^(\d*\.\d{0,2}).*$/, "$1"); const [whole, frac] = clean.split("."); setRaw(whole ? `${plain.format(Number(whole))}${frac !== undefined ? `.${frac}` : ""}` : frac !== undefined ? `0.${frac}` : ""); }; const send = async () => { if (sending || !ready) return; setSending(true); setError(""); try { const res = await onSend({ from, to, method, amount, fee: m.fee, memo: memo.trim(), arrives }); setReference(res.reference); setStep("sent"); } catch (e) { setError((e as Error)?.message || "The transfer wasn't sent. Nothing left your account."); } finally { setSending(false); } }; const again = () => { setRaw(""); setMemo(""); setReference(""); setError(""); setStep("compose"); requestAnimationFrame(() => amountRef.current?.focus()); }; return (
    {["Details", "Review", "Sent"].map((s, i) => { const at = ["compose", "review", "sent"].indexOf(step); return
  1. {s}
  2. ; })}
{step === "compose" && (
{ e.preventDefault(); if (ready) setStep("review"); }} noValidate>

Send money

typed(e.target.value)} aria-invalid={!!problem || undefined} aria-describedby={`${id}-avail`} />

{problem || `${money.format(account.balance / 100)} available in ${account.name}`}

How it travels {methods.map((x) => { const a = addBusinessDays(today, x.days[0], weekend), b = addBusinessDays(today, x.days[1], weekend); return ( ); })}
)} {step === "review" && (

Check the details

{money.format(amount / 100)}

To
{recipient.name}{recipient.bank} ••{recipient.last4}
From
{account.name}••{account.last4}
Method
{m.label}
Arrives
{when}
Fee
{m.fee ? money.format(m.fee / 100) : "Free"}
{memo.trim() &&
Memo
{memo.trim()}
}
Total from {account.name}
{money.format(total / 100)}
{error &&

{error}

}
)} {step === "sent" && (

{money.format(amount / 100)} is on its way

{recipient.name} should see it {when === "today" ? "today" : `by ${day.format(new Date(`${arrives[1]}T12:00:00Z`))}`}.

Reference {reference}

)}
); }