From 86d8ca8af9ae170280733449eb1cb67057e405d9 Mon Sep 17 00:00:00 2001 From: umohjosephemmanuel-cyber Date: Thu, 18 Jun 2026 09:15:25 +0100 Subject: [PATCH] feat(format): add locale-aware currency formatter and apply to receipt --- app/send/components/AutomaticSplitCard.tsx | 40 +++++++++------- app/send/components/ReviewStep.tsx | 9 +++- app/send/page.tsx | 9 +++- components/CurrentMoneySplitWidget.tsx | 7 ++- components/TransactionSuccessReceipt.tsx | 20 ++++++-- lib/utils/format-currency.test.ts | 30 ++++++++++++ lib/utils/format-currency.ts | 55 ++++++++++++++++++++++ package-lock.json | 2 + 8 files changed, 145 insertions(+), 27 deletions(-) create mode 100644 lib/utils/format-currency.test.ts create mode 100644 lib/utils/format-currency.ts diff --git a/app/send/components/AutomaticSplitCard.tsx b/app/send/components/AutomaticSplitCard.tsx index b9ee49a..ac3bdff 100644 --- a/app/send/components/AutomaticSplitCard.tsx +++ b/app/send/components/AutomaticSplitCard.tsx @@ -9,11 +9,13 @@ import { Shield, Info, } from "lucide-react"; +import { useClientLocale } from "@/lib/i18n/client"; +import { formatCurrency } from "@/lib/utils/format-currency"; interface SplitCategoryProps { icon: React.ElementType; label: string; - amount: number; + amount: string; percentage: number; } @@ -34,7 +36,7 @@ const SplitCategory = ({
- ${amount.toFixed(2)} + {amount} {percentage}% @@ -53,9 +55,12 @@ const SplitCategory = ({ interface AutomaticSplitCardProps { amount?: number; + currency?: string; } -export default function AutomaticSplitCard({ amount: externalAmount }: AutomaticSplitCardProps) { +export default function AutomaticSplitCard({ amount: externalAmount, currency }: AutomaticSplitCardProps) { + const locale = useClientLocale(); + const resolvedCurrency = currency ?? "USD"; const [internalAmount, setInternalAmount] = useState(""); const total = externalAmount !== undefined ? externalAmount : (parseFloat(internalAmount) || 0); @@ -67,10 +72,7 @@ export default function AutomaticSplitCard({ amount: externalAmount }: Automatic { icon: Shield, label: "Insurance", percentage: 5 }, ] as const; - const displayTotal = total.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); + const displayTotal = formatCurrency(total, resolvedCurrency, locale); return (
@@ -100,15 +102,19 @@ export default function AutomaticSplitCard({ amount: externalAmount }: Automatic {/* Categories */}
- {categories.map((cat, index) => ( - - ))} + {categories.map((cat, index) => { + const splitValue = (total * cat.percentage) / 100; + + return ( + + ); + })}
{/* Divider + Total */} @@ -118,7 +124,7 @@ export default function AutomaticSplitCard({ amount: externalAmount }: Automatic Total Amount - ${displayTotal} + {displayTotal}
diff --git a/app/send/components/ReviewStep.tsx b/app/send/components/ReviewStep.tsx index 963e154..d736516 100644 --- a/app/send/components/ReviewStep.tsx +++ b/app/send/components/ReviewStep.tsx @@ -2,6 +2,8 @@ import { Zap, ArrowLeft, ShieldCheck, User, CreditCard } from "lucide-react"; import AutomaticSplitCard from "./AutomaticSplitCard"; +import { useClientLocale } from "@/lib/i18n/client"; +import { formatCurrency } from "@/lib/utils/format-currency"; interface ReviewStepProps { recipient: string; @@ -20,6 +22,9 @@ export default function ReviewStep({ onBack, onEmergencyAction, }: ReviewStepProps) { + const locale = useClientLocale(); + const formattedAmount = formatCurrency(amount, currency, locale); + return (
@@ -53,7 +58,7 @@ export default function ReviewStep({

Amount to Send

- {amount.toLocaleString()} {currency} + {formattedAmount}

@@ -107,7 +112,7 @@ export default function ReviewStep({
- +
diff --git a/app/send/page.tsx b/app/send/page.tsx index 166a403..a2705a7 100644 --- a/app/send/page.tsx +++ b/app/send/page.tsx @@ -8,6 +8,8 @@ import RecipientAddressInput from "./components/RecipientAddressInput"; import AmountCurrencySection from "./components/AmountCurrencySection"; import ReviewStep from "./components/ReviewStep"; import TransactionSuccessReceipt from "@/components/TransactionSuccessReceipt"; +import { useClientLocale } from "@/lib/i18n/client"; +import { formatCurrency } from "@/lib/utils/format-currency"; type Step = "recipient" | "amount" | "review"; @@ -34,6 +36,8 @@ export default function SendMoney() { setStep("review"); }; + const locale = useClientLocale(); + const handleConfirm = () => { // Simulate transaction processing const mockData = { @@ -54,12 +58,13 @@ export default function SendMoney() { setTransactionData(mockData); setIsSubmitted(true); + const formattedAmount = formatCurrency(amount, currency, locale); toast({ variant: "success", title: "Transfer submitted", - description: `Successfully sent ${amount} ${currency} to ${mockData.recipientAddress}.`, + description: `Successfully sent ${formattedAmount} to ${mockData.recipientAddress}.`, }); - console.log(`Send ${amount} ${currency} to ${recipient}`); + console.log(`Send ${formattedAmount} to ${recipient}`); }; return ( diff --git a/components/CurrentMoneySplitWidget.tsx b/components/CurrentMoneySplitWidget.tsx index 668a307..91405ca 100644 --- a/components/CurrentMoneySplitWidget.tsx +++ b/components/CurrentMoneySplitWidget.tsx @@ -1,7 +1,12 @@ +"use client"; + import Link from 'next/link'; import { Settings } from 'lucide-react'; +import { useClientLocale } from '@/lib/i18n/client'; +import { formatCurrency } from '@/lib/utils/format-currency'; export default function CurrentMoneySplitWidget() { + const locale = useClientLocale(); const allocations = [ { label: 'Daily Spending', amount: 600, percentage: 50 }, { label: 'Savings', amount: 360, percentage: 30 }, @@ -36,7 +41,7 @@ export default function CurrentMoneySplitWidget() { {item.label}
- ${item.amount} + {formatCurrency(item.amount, 'USD', locale)} {item.percentage}%
diff --git a/components/TransactionSuccessReceipt.tsx b/components/TransactionSuccessReceipt.tsx index dd0486b..193ab21 100644 --- a/components/TransactionSuccessReceipt.tsx +++ b/components/TransactionSuccessReceipt.tsx @@ -11,11 +11,12 @@ import { TrendingUp, FileText, Shield, - ArrowRight, ChevronRight, X } from "lucide-react"; import Link from "next/link"; +import { useClientLocale } from "@/lib/i18n/client"; +import { formatCurrency } from "@/lib/utils/format-currency"; interface SplitDetail { icon: React.ElementType; @@ -54,6 +55,13 @@ export default function TransactionSuccessReceipt({ onClose }: TransactionSuccessReceiptProps) { const [copied, setCopied] = useState(false); + const locale = useClientLocale(); + const formattedAmount = formatCurrency(amount, currency, locale); + const formattedFee = formatCurrency(fee, currency, locale, { + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }); + const showCurrencyLabel = !formattedAmount.endsWith(` ${currency}`); const truncate = (str: string) => `${str.substring(0, 6)}...${str.substring(str.length - 6)}`; @@ -99,8 +107,10 @@ export default function TransactionSuccessReceipt({
Amount Sent
- ${amount.toFixed(2)} - {currency} + {formattedAmount} + {showCurrencyLabel && ( + {currency} + )}
@@ -119,7 +129,7 @@ export default function TransactionSuccessReceipt({
Network Fee - ${fee.toFixed(4)} {currency} + {formattedFee}
Transaction ID @@ -149,7 +159,7 @@ export default function TransactionSuccessReceipt({ {split.label}
- ${split.amount.toFixed(2)} + {formatCurrency(split.amount, currency, locale)}
{ + it("formats USD with en-US locale", () => { + expect(formatCurrency(1234.5, "USD", "en-US")).toBe("$1,234.50"); + }); + + it("formats negative values consistently", () => { + expect(formatCurrency(-9876.543, "USD", "en-US")).toBe("-$9,876.54"); + }); + + it("formats zero values correctly", () => { + expect(formatCurrency(0, "USD", "en-US")).toBe("$0.00"); + }); + + it("falls back for unknown stablecoin codes", () => { + expect(formatCurrency(1234.5, "USDC", "en-US")).toBe("1,234.50 USDC"); + }); + + it("formats es locale using locale-specific separators", () => { + const result = formatCurrency(1234.5, "USD", "es-ES"); + expect(result).toMatch(/1[.,\u202F]?234[.,]50/); + }); +}); + +describe("formatAmount alias", () => { + it("behaves the same as formatCurrency", () => { + expect(formatAmount(1234.5, "USD", "en-US")).toBe(formatCurrency(1234.5, "USD", "en-US")); + }); +}); diff --git a/lib/utils/format-currency.ts b/lib/utils/format-currency.ts new file mode 100644 index 0000000..84f6bda --- /dev/null +++ b/lib/utils/format-currency.ts @@ -0,0 +1,55 @@ +export type FormatCurrencyOptions = { + locale?: string; + minimumFractionDigits?: number; + maximumFractionDigits?: number; +}; + +const DEFAULT_MINIMUM_FRACTION_DIGITS = 2; +const DEFAULT_MAXIMUM_FRACTION_DIGITS = 2; + +function formatNumber( + amount: number, + locale: string, + minimumFractionDigits: number, + maximumFractionDigits: number +) { + return new Intl.NumberFormat(locale, { + minimumFractionDigits, + maximumFractionDigits, + }).format(amount); +} + +/** + * Formats a numeric amount for a locale and currency/asset code. + * + * Falls back to a plain localized number with a currency suffix when + * Intl does not recognize the currency code (for example, stablecoin codes). + */ +export function formatCurrency( + amount: number, + currency: string, + locale = "en", + options: FormatCurrencyOptions = {} +): string { + const { minimumFractionDigits = DEFAULT_MINIMUM_FRACTION_DIGITS, maximumFractionDigits = DEFAULT_MAXIMUM_FRACTION_DIGITS } = options; + const normalizedCurrency = currency?.trim(); + const resolvedLocale = locale || "en"; + + if (!normalizedCurrency) { + return formatNumber(amount, resolvedLocale, minimumFractionDigits, maximumFractionDigits); + } + + try { + return new Intl.NumberFormat(resolvedLocale, { + style: "currency", + currency: normalizedCurrency, + minimumFractionDigits, + maximumFractionDigits, + }).format(amount); + } catch { + const formattedAmount = formatNumber(amount, resolvedLocale, minimumFractionDigits, maximumFractionDigits); + return `${formattedAmount} ${normalizedCurrency}`; + } +} + +export const formatAmount = formatCurrency; diff --git a/package-lock.json b/package-lock.json index d95e5d8..467aee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10865,6 +10865,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -14227,6 +14228,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true,