From e18903327f097cb6b50c02a66bc2d7f9b1512b6f Mon Sep 17 00:00:00 2001 From: temycodes Date: Thu, 23 Apr 2026 09:05:04 +0100 Subject: [PATCH 01/36] fix: replace index-based list keys with stable currency ids in rates page --- app/rates/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/rates/page.tsx b/app/rates/page.tsx index aafbcdb..db09549 100644 --- a/app/rates/page.tsx +++ b/app/rates/page.tsx @@ -81,8 +81,8 @@ export default function RatesPage() { { currency: "XOF", rate: rates.acbu_xof }, ] .filter(r => r.rate != null) - .map((r, i) => ( - + .map((r) => ( +

ACBU/{r.currency} From 6984844d6ee9b0dcf80969e10345af313a53fe38 Mon Sep 17 00:00:00 2001 From: temycodes Date: Thu, 23 Apr 2026 09:08:46 +0100 Subject: [PATCH 02/36] Fix unstable keys in mint rates list --- app/mint/page.tsx | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/app/mint/page.tsx b/app/mint/page.tsx index 2eaedef..052772d 100644 --- a/app/mint/page.tsx +++ b/app/mint/page.tsx @@ -50,6 +50,16 @@ function estimateAcbuFromFiat( return n / localPerAcbu; } +function formatRate(rate: string | number | null | undefined): string { + if (rate == null || rate === '') return '—'; + + return new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 6, + useGrouping: true, + }).format(Number(rate)); +} + /** * Mint and Burn page for ACBU tokens. */ @@ -72,9 +82,27 @@ export default function MintPage() { const [fiatAmount, setFiatAmount] = useState(''); const [mintQuoteRates, setMintQuoteRates] = useState(null); const [mintAcbuReceived, setMintAcbuReceived] = useState(null); - const rateRows = Array.isArray((rates as { rates?: Array<{ currency?: string; rate?: number }> } | null)?.rates) - ? ((rates as { rates?: Array<{ currency?: string; rate?: number }> }).rates ?? []) - : []; + const rateRows = useMemo( + () => + rates + ? [ + { currency: 'USD', rate: rates.acbu_usd }, + { currency: 'EUR', rate: rates.acbu_eur }, + { currency: 'GBP', rate: rates.acbu_gbp }, + { currency: 'NGN', rate: rates.acbu_ngn }, + { currency: 'KES', rate: rates.acbu_kes }, + { currency: 'ZAR', rate: rates.acbu_zar }, + { currency: 'RWF', rate: rates.acbu_rwf }, + { currency: 'GHS', rate: rates.acbu_ghs }, + { currency: 'EGP', rate: rates.acbu_egp }, + { currency: 'MAD', rate: rates.acbu_mad }, + { currency: 'TZS', rate: rates.acbu_tzs }, + { currency: 'UGX', rate: rates.acbu_ugx }, + { currency: 'XOF', rate: rates.acbu_xof }, + ].filter((row) => row.rate != null) + : [], + [rates], + ); const estimatedMintAcbu = useMemo( () => estimateAcbuFromFiat(fiatAmount, selectedFiatCurrency, mintQuoteRates), @@ -578,11 +606,11 @@ export default function MintPage() { {ratesLoading ? ( ) : rateRows.length ? ( - rateRows.map((r: { currency?: string; rate?: number }) => ( - + rateRows.map((r) => ( +

-

ACBU/{r.currency ?? 'Rate'}

-

{r.rate != null ? String(r.rate) : '—'}

+

ACBU/{r.currency}

+

{formatRate(r.rate)}

)) From 64805471080707e9a83e1f23ac7bb6c456b5ee0c Mon Sep 17 00:00:00 2001 From: Ikechukwu Israel Date: Thu, 23 Apr 2026 09:21:39 +0100 Subject: [PATCH 03/36] fix: standardize back navigation to prevent unintentional app shell exit (#220) - Add BackButton component with fallback-safe navigation pattern - Component uses router.back() only when in-app history exists - Falls back to explicit in-app route when arriving via direct URL/external link - SSR-safe with window access guard - Audit confirms 0 router.back() calls exist in codebase - All existing back navigation already uses explicit Link components --- components/navigation/BackButton.tsx | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 components/navigation/BackButton.tsx diff --git a/components/navigation/BackButton.tsx b/components/navigation/BackButton.tsx new file mode 100644 index 0000000..c874d8b --- /dev/null +++ b/components/navigation/BackButton.tsx @@ -0,0 +1,61 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft } from 'lucide-react'; + +interface BackButtonProps { + fallbackHref: string; // required — explicit in-app fallback route + label?: string; // default: "Back" + className?: string; + children?: React.ReactNode; +} + +/** + * BackButton component with fallback-safe navigation. + * + * Uses browser back() only when there's in-app history to go back to. + * Otherwise navigates to the explicit fallback route, ensuring users + * never accidentally leave the app shell when arriving via direct URL, + * external link, or browser bookmark. + * + * @example + * + * @example + * + */ +export function BackButton({ + fallbackHref, + label = 'Back', + className = '', + children +}: BackButtonProps) { + const router = useRouter(); + + const handleBack = () => { + // Only use browser back if we have in-app history + // window.history.state.idx is set by Next.js router — 0 means + // this was the entry page (direct URL, external link, etc.) + if (typeof window !== 'undefined' && window.history.state?.idx > 0) { + router.back(); + } else { + router.push(fallbackHref); + } + }; + + return ( + + ); +} From be143d50939fe2b9abfd36f5a5887763d61a1266 Mon Sep 17 00:00:00 2001 From: victor-134 Date: Thu, 23 Apr 2026 09:24:35 +0100 Subject: [PATCH 04/36] F-050: Strong burn form validation and dependency fix --- app/burn/page.tsx | 342 ++++++++++++++++++++++++++++++---------------- package.json | 4 +- 2 files changed, 228 insertions(+), 118 deletions(-) diff --git a/app/burn/page.tsx b/app/burn/page.tsx index 3bff213..acfbbb6 100644 --- a/app/burn/page.tsx +++ b/app/burn/page.tsx @@ -6,7 +6,7 @@ import { PageContainer } from "@/components/layout/page-container"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, AlertCircle, CheckCircle } from "lucide-react"; import { useApiOpts } from "@/hooks/use-api"; import * as burnApi from "@/lib/api/burn"; import type { BurnRecipientAccount } from "@/types/api"; @@ -15,6 +15,39 @@ import { useStellarWalletsKit } from "@/lib/stellar-wallets-kit"; import { getWalletSecretAnyLocal } from "@/lib/wallet-storage"; import { Keypair } from "@stellar/stellar-sdk"; import { submitBurnRedeemSingleClient } from "@/lib/stellar/burning"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { cn } from "@/lib/utils"; + +const burnSchema = z.object({ + acbuAmount: z.string().refine((val) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, { + message: "Amount must be greater than 0", + }), + currency: z.string().length(3, "Currency must be exactly 3 uppercase letters"), + accountNumber: z.string() + .min(5, "Account number is too short") + .max(20, "Account number is too long") + .regex(/^\d+$/, "Account number must contain only digits"), + bankCode: z.string() + .min(3, "Bank code is too short") + .max(10, "Bank code is too long") + .regex(/^[A-Za-z0-9]+$/, "Bank code must be alphanumeric"), + accountName: z.string() + .min(3, "Account name is too short") + .max(100, "Account name is too long"), +}); + +type BurnFormValues = z.infer; const formatCurrency = (amount: string, currency: string) => { const value = parseFloat(amount); @@ -34,43 +67,45 @@ export default function BurnPage() { const opts = useApiOpts(); const { userId, stellarAddress } = useAuth(); const kit = useStellarWalletsKit(); - const [acbuAmount, setAcbuAmount] = useState(""); - const [currency, setCurrency] = useState("NGN"); - const [accountNumber, setAccountNumber] = useState(""); - const [bankCode, setBankCode] = useState(""); - const [accountName, setAccountName] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [txId, setTxId] = useState(null); - const recipientAccount: BurnRecipientAccount = { - account_number: accountNumber.trim(), - bank_code: bankCode.trim(), - account_name: accountName.trim(), - type: "bank", - }; + const form = useForm({ + resolver: zodResolver(burnSchema), + defaultValues: { + acbuAmount: "", + currency: "NGN", + accountNumber: "", + bankCode: "", + accountName: "", + }, + mode: "onChange", + }); - const isValid = - acbuAmount && - parseFloat(acbuAmount) > 0 && - currency.length === 3 && - accountNumber.trim().length >= 5 && - accountNumber.trim().length <= 20 && - bankCode.trim().length >= 3 && - bankCode.trim().length <= 10 && - accountName.trim().length >= 3 && - accountName.trim().length <= 100; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!isValid) return; + const { watch, handleSubmit: formHandleSubmit } = form; + const acbuAmount = watch("acbuAmount"); + const currency = watch("currency"); + + const onSubmit = async (values: BurnFormValues) => { setError(""); setLoading(true); + setTxId(null); + try { if (!userId) throw new Error("Not signed in"); if (!stellarAddress) throw new Error("No linked Stellar wallet address."); + + const recipientAccount: BurnRecipientAccount = { + account_number: values.accountNumber.trim(), + bank_code: values.bankCode.trim(), + account_name: values.accountName.trim(), + type: "bank", + }; + const secret = await getWalletSecretAnyLocal(userId, stellarAddress); let burnTxHash: string; + if (secret) { const localPubKey = Keypair.fromSecret(secret).publicKey(); if (stellarAddress && localPubKey !== stellarAddress) { @@ -80,8 +115,8 @@ export default function BurnPage() { } const submit = await submitBurnRedeemSingleClient({ userAddress: stellarAddress, - amountAcbu: acbuAmount, - currency, + amountAcbu: values.acbuAmount, + currency: values.currency, userSecret: secret, }); burnTxHash = submit.transactionHash; @@ -113,22 +148,30 @@ export default function BurnPage() { } const submit = await submitBurnRedeemSingleClient({ userAddress: stellarAddress, - amountAcbu: acbuAmount, - currency, + amountAcbu: values.acbuAmount, + currency: values.currency, external: { kit, address }, }); burnTxHash = submit.transactionHash; } + const res = await burnApi.burnAcbu( - acbuAmount, - currency, + values.acbuAmount, + values.currency, recipientAccount, opts, burnTxHash, ); setTxId(res.transaction_id); + form.reset({ ...values, acbuAmount: "" }); // Reset amount but keep details for convenience? Or full reset? } catch (e) { - setError(e instanceof Error ? e.message : "Burn failed"); + console.error("Burn error:", e); + // Handle server-side validation errors if they follow a specific format + if (e instanceof Error) { + setError(e.message); + } else { + setError("Burn failed. Please check your bank details and try again."); + } } finally { setLoading(false); } @@ -153,97 +196,164 @@ export default function BurnPage() {

Burn ACBU and withdraw to your bank or mobile money account.

- {error &&

{error}

} - {txId && ( -

- Transaction submitted: {txId} -

+ + {error && ( +
+ +

{error}

+
)} -
-
- - setAcbuAmount(e.target.value)} - className="border-border" - /> - - {acbuAmount && ( -

- ≈ {formatCurrency(acbuAmount, currency)} -

- )} + + {txId && ( +
+ +

+ Transaction submitted successfully! ID: {txId} +

+ )} + + + ( + + ACBU amount + + + + {field.value && ( +

+ ≈ {formatCurrency(field.value, currency)} +

+ )} + +
+ )} + /> -
- - - setCurrency(e.target.value.toUpperCase().slice(0, 3)) - } - className="border-border" - maxLength={3} + ( + + Currency (3 letters) + + { + const val = e.target.value.toUpperCase().slice(0, 3); + field.onChange(val); + }} + className="border-border" + maxLength={3} + /> + + + The target currency for your withdrawal (e.g., NGN, KES). + + + + )} /> -
-
- - setAccountNumber(e.target.value.replace(/\D/g, ""))} - className="border-border" + + ( + + Account number + + { + const val = e.target.value.replace(/\D/g, ""); + field.onChange(val); + }} + className="border-border" + maxLength={20} + /> + + + {currency === "NGN" ? "Nigerian bank accounts are typically 10 digits." : "Standard bank account number."} + + + + )} /> -
-
- - setBankCode(e.target.value.slice(0, 10))} - className="border-border" + + ( + + Bank code + + { + const val = e.target.value.toUpperCase().slice(0, 10); + field.onChange(val); + }} + className="border-border" + maxLength={10} + /> + + + Sort code, SWIFT/BIC, or local bank routing code. + + + + )} /> -
-
- - setAccountName(e.target.value)} - className="border-border" + + ( + + Account name + + + + + The official name registered with the bank. + + + + )} /> -
- - + + + + diff --git a/package.json b/package.json index 1096d7d..0ae0cca 100644 --- a/package.json +++ b/package.json @@ -66,12 +66,12 @@ "zod": "3.25.76" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@eslint/js": "^9.11.1", "@tailwindcss/postcss": "^4.1.9", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^10.2.0", + "eslint": "^9.11.1", "eslint-plugin-react": "^7.37.5", "globals": "^17.5.0", "postcss": "^8.5", From d0acd9eb617e19fb830885a3754aeb4a85d725c6 Mon Sep 17 00:00:00 2001 From: victor-134 Date: Thu, 23 Apr 2026 09:29:21 +0100 Subject: [PATCH 05/36] Fix TypeScript implicit any errors --- app/burn/page.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/burn/page.tsx b/app/burn/page.tsx index acfbbb6..a93c862 100644 --- a/app/burn/page.tsx +++ b/app/burn/page.tsx @@ -30,7 +30,7 @@ import { import { cn } from "@/lib/utils"; const burnSchema = z.object({ - acbuAmount: z.string().refine((val) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, { + acbuAmount: z.string().refine((val: string) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, { message: "Amount must be greater than 0", }), currency: z.string().length(3, "Currency must be exactly 3 uppercase letters"), @@ -218,7 +218,7 @@ export default function BurnPage() { ( + render={({ field }: { field: any }) => ( ACBU amount @@ -244,14 +244,14 @@ export default function BurnPage() { ( + render={({ field }: { field: any }) => ( Currency (3 letters) { + onChange={(e: React.ChangeEvent) => { const val = e.target.value.toUpperCase().slice(0, 3); field.onChange(val); }} @@ -270,7 +270,7 @@ export default function BurnPage() { ( + render={({ field }: { field: any }) => ( Account number @@ -279,7 +279,7 @@ export default function BurnPage() { inputMode="numeric" placeholder="1234567890" {...field} - onChange={(e) => { + onChange={(e: React.ChangeEvent) => { const val = e.target.value.replace(/\D/g, ""); field.onChange(val); }} @@ -298,7 +298,7 @@ export default function BurnPage() { ( + render={({ field }: { field: any }) => ( Bank code @@ -306,7 +306,7 @@ export default function BurnPage() { type="text" placeholder="Enter bank code" {...field} - onChange={(e) => { + onChange={(e: React.ChangeEvent) => { const val = e.target.value.toUpperCase().slice(0, 10); field.onChange(val); }} @@ -325,7 +325,7 @@ export default function BurnPage() { ( + render={({ field }: { field: any }) => ( Account name From 23a5b9391be2da4646319c6195d44e879cfb4eb3 Mon Sep 17 00:00:00 2001 From: DevSolex Date: Thu, 23 Apr 2026 09:55:29 +0100 Subject: [PATCH 06/36] fix(reserves): add unit explanation tooltips linking to RESERVE_MANAGEMENT docs Closes #228 - Add MetricLabel component with Info icon tooltip on each health metric - Metrics covered: Total reserves, Total ACBU supply, Collateral ratio, Health - Per-currency metrics: Balance, USD value, Target weight, Actual weight - Each tooltip links to /docs/RESERVE_MANAGEMENT for deeper reading - Addresses F-057: users misinterpreting health metrics on reserves page --- app/reserves/page.tsx | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/app/reserves/page.tsx b/app/reserves/page.tsx index e308b82..7d3a204 100644 --- a/app/reserves/page.tsx +++ b/app/reserves/page.tsx @@ -5,12 +5,31 @@ import Link from 'next/link'; import { PageContainer } from '@/components/layout/page-container'; import { Card } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { ArrowLeft } from 'lucide-react'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { ArrowLeft, Info } from 'lucide-react'; import { useApiOpts } from '@/hooks/use-api'; import * as reservesApi from '@/lib/api/reserves'; import type { ReservesResponse } from '@/types/api'; import { formatAmount } from '@/lib/utils'; +const DOCS_URL = '/docs/RESERVE_MANAGEMENT'; + +function MetricLabel({ label, tip }: { label: string; tip: string }) { + return ( + + {label} + + + + + + + {tip} + + + ); +} + export default function ReservesPage() { const opts = useApiOpts(); const [data, setData] = useState(null); @@ -64,7 +83,7 @@ export default function ReservesPage() {
- Total reserves + On-chain USD value (7-dec fixed).
@@ -77,7 +96,7 @@ export default function ReservesPage() {
- Total ACBU supply + Minting contract tracked supply.
@@ -89,7 +108,7 @@ export default function ReservesPage() {
- Collateral ratio + Reserves ÷ supply (USD terms).
@@ -104,7 +123,7 @@ export default function ReservesPage() {
- Health + Issuer-reported status.
@@ -153,23 +172,23 @@ export default function ReservesPage() {
- Balance + {formatAmount(fixed7ToNumber(c.amount), 2)} {c.currency}
- USD value + USD {formatAmount(fixed7ToNumber(c.value_usd), 2)}
- Target + {formatPct(c.target_weight_bps)}
- Actual + {formatPct(c.actual_weight_bps)}
From 09bf9a9b72f46a92783773848f9bd0381fbde98e Mon Sep 17 00:00:00 2001 From: Nathan Iheanyi Date: Thu, 23 Apr 2026 10:00:40 +0100 Subject: [PATCH 07/36] feat: standardize money errors using useApiError hook and align ESLint --- app/burn/page.tsx | 14 ++-- app/currency/page.tsx | 16 ++-- app/fiat/page.tsx | 22 +++--- app/mint/page.tsx | 33 ++++---- app/savings/deposit/page.tsx | 14 ++-- app/savings/withdraw/page.tsx | 14 ++-- app/send/page.tsx | 14 ++-- components/ui/api-error-display.tsx | 106 ++++++++++++++++++++++++++ hooks/use-api-error.ts | 113 ++++++++++++++++++++++++++++ package.json | 9 ++- 10 files changed, 295 insertions(+), 60 deletions(-) create mode 100644 components/ui/api-error-display.tsx create mode 100644 hooks/use-api-error.ts diff --git a/app/burn/page.tsx b/app/burn/page.tsx index 3bff213..a924293 100644 --- a/app/burn/page.tsx +++ b/app/burn/page.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ArrowLeft } from "lucide-react"; import { useApiOpts } from "@/hooks/use-api"; +import { useApiError } from "@/hooks/use-api-error"; +import { ApiErrorDisplay } from "@/components/ui/api-error-display"; import * as burnApi from "@/lib/api/burn"; import type { BurnRecipientAccount } from "@/types/api"; import { useAuth } from "@/contexts/auth-context"; @@ -34,12 +36,12 @@ export default function BurnPage() { const opts = useApiOpts(); const { userId, stellarAddress } = useAuth(); const kit = useStellarWalletsKit(); + const { uiError, setApiError, clearError, isSubmitDisabled } = useApiError(); const [acbuAmount, setAcbuAmount] = useState(""); const [currency, setCurrency] = useState("NGN"); const [accountNumber, setAccountNumber] = useState(""); const [bankCode, setBankCode] = useState(""); const [accountName, setAccountName] = useState(""); - const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [txId, setTxId] = useState(null); @@ -64,7 +66,7 @@ export default function BurnPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!isValid) return; - setError(""); + clearError(); setLoading(true); try { if (!userId) throw new Error("Not signed in"); @@ -128,7 +130,7 @@ export default function BurnPage() { ); setTxId(res.transaction_id); } catch (e) { - setError(e instanceof Error ? e.message : "Burn failed"); + setApiError(e); } finally { setLoading(false); } @@ -153,7 +155,9 @@ export default function BurnPage() {

Burn ACBU and withdraw to your bank or mobile money account.

- {error &&

{error}

} + {uiError && ( + + )} {txId && (

Transaction submitted: {txId} @@ -238,7 +242,7 @@ export default function BurnPage() {

- {submitError && ( -

{submitError}

+ {uiError && ( + )} diff --git a/app/fiat/page.tsx b/app/fiat/page.tsx index 4c8aac1..f7ce61e 100644 --- a/app/fiat/page.tsx +++ b/app/fiat/page.tsx @@ -6,21 +6,22 @@ import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useApiOpts } from '@/hooks/use-api'; +import { useApiError } from '@/hooks/use-api-error'; +import { ApiErrorDisplay } from '@/components/ui/api-error-display'; import * as fiatApi from '@/lib/api/fiat'; -import { getApiErrorMessage } from '@/lib/api/client'; import { useAuth } from '@/contexts/auth-context'; import { getWalletSecretAnyLocal } from '@/lib/wallet-storage'; import { ensureDemoFiatTrustlineClient } from '@/lib/stellar/trustlines'; import { useStellarWalletsKit } from '@/lib/stellar-wallets-kit'; -import { AlertCircle, Building2, Plus } from 'lucide-react'; +import { Building2, Plus } from 'lucide-react'; import { Keypair } from '@stellar/stellar-sdk'; export default function FiatSimPage() { const opts = useApiOpts(); const { userId, stellarAddress } = useAuth(); const kit = useStellarWalletsKit(); + const { uiError, setApiError, clearError, isSubmitDisabled } = useApiError(); const [accounts, setAccounts] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); const [actionLoading, setActionLoading] = useState(null); const [lastFaucetTx, setLastFaucetTx] = useState(null); @@ -32,7 +33,7 @@ export default function FiatSimPage() { const data = await fiatApi.getFiatAccounts(opts); setAccounts(data.accounts || []); } catch (e: unknown) { - setError(getApiErrorMessage(e)); + setApiError(e); } finally { setLoading(false); } @@ -47,7 +48,7 @@ export default function FiatSimPage() { if (!faucetAmount || parseFloat(faucetAmount) <= 0) return; setActionLoading('faucet'); - setError(''); + clearError(); setLastFaucetTx(null); try { if (!userId) throw new Error('Not logged in'); @@ -89,7 +90,7 @@ export default function FiatSimPage() { setLastFaucetTx(res.transaction_hash); setFaucetAmount(''); } catch (e: unknown) { - setError(getApiErrorMessage(e)); + setApiError(e); } finally { setActionLoading(null); } @@ -106,11 +107,8 @@ export default function FiatSimPage() {

- {error && ( -
- -

{error}

-
+ {uiError && ( + )} {lastFaucetTx && ( @@ -148,7 +146,7 @@ export default function FiatSimPage() { onChange={(e) => setFaucetAmount(e.target.value)} className="flex-1 min-w-[120px]" /> - diff --git a/app/mint/page.tsx b/app/mint/page.tsx index 2eaedef..b3818d8 100644 --- a/app/mint/page.tsx +++ b/app/mint/page.tsx @@ -19,6 +19,8 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { ArrowDown, ArrowUp, ArrowLeft } from 'lucide-react'; import { useApiOpts } from '@/hooks/use-api'; +import { useApiError } from '@/hooks/use-api-error'; +import { ApiErrorDisplay } from '@/components/ui/api-error-display'; import { useBalance } from '@/hooks/use-balance'; import { useAuth } from '@/contexts/auth-context'; import { getWalletSecretAnyLocal } from '@/lib/wallet-storage'; @@ -58,13 +60,13 @@ export default function MintPage() { const { userId, stellarAddress } = useAuth(); const { balance, balanceSource, loading: balanceLoading, refresh: refreshBalance } = useBalance(); const kit = useStellarWalletsKit(); + const { uiError: mintUiError, setApiError: setMintApiError, clearError: clearMintError, isSubmitDisabled: isMintDisabled } = useApiError(); + const { uiError: burnUiError, setApiError: setBurnApiError, clearError: clearBurnError, isSubmitDisabled: isBurnDisabled } = useApiError(); const [activeTab, setActiveTab] = useState<'mint' | 'burn' | 'rates'>('mint'); const [step, setStep] = useState<'input' | 'confirm' | 'success'>('input'); const [burnAmount, setBurnAmount] = useState(''); - const [burnError, setBurnError] = useState(''); const [rates, setRates] = useState(null); const [ratesLoading, setRatesLoading] = useState(false); - const [mintError, setMintError] = useState(''); const [txId, setTxId] = useState(null); const [executing, setExecuting] = useState(false); const [fiatAccounts, setFiatAccounts] = useState([]); @@ -119,14 +121,14 @@ export default function MintPage() { }, [activeTab, opts.token]); const handleMintConfirm = () => { - setMintError(""); + clearMintError(); setStep("confirm"); }; const handleBurnConfirm = () => setStep("confirm"); const handleExecuteMint = async () => { if (!fiatAmount || parseFloat(fiatAmount) <= 0 || !selectedFiatCurrency) return; - setMintError(""); + clearMintError(); setExecuting(true); try { // Default setup: make sure the recipient trusts the ACBU asset @@ -225,7 +227,7 @@ export default function MintPage() { refreshBalance(); setStep("success"); } catch (e) { - setMintError(e instanceof Error ? e.message : "Mint failed"); + setMintApiError(e); } finally { setExecuting(false); } @@ -233,7 +235,7 @@ export default function MintPage() { const handleExecuteBurn = async () => { if (!burnAmount || parseFloat(burnAmount) <= 0 || !selectedFiatCurrency) return; - setBurnError(""); + clearBurnError(); setExecuting(true); try { if (!userId) { @@ -301,7 +303,7 @@ export default function MintPage() { setTxId(res.transaction_id || res.transactionId || null); setStep("success"); } catch (e) { - setBurnError(e instanceof Error ? e.message : "Burn failed"); + setBurnApiError(e); } finally { setExecuting(false); } @@ -317,7 +319,8 @@ export default function MintPage() { setStep("input"); setFiatAmount(""); setBurnAmount(""); - setBurnError(""); + clearBurnError(); + clearMintError(); setTxId(null); setMintAcbuReceived(null); }; @@ -387,10 +390,8 @@ export default function MintPage() { Mint ACBU via custodial on-ramp (demo basket fiat held on the minting contract).

- {mintError && ( -

- {mintError} -

+ {mintUiError && ( + )}
diff --git a/app/savings/withdraw/page.tsx b/app/savings/withdraw/page.tsx index b838f16..7913a80 100644 --- a/app/savings/withdraw/page.tsx +++ b/app/savings/withdraw/page.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ArrowLeft } from "lucide-react"; import { useApiOpts } from "@/hooks/use-api"; +import { useApiError } from "@/hooks/use-api-error"; +import { ApiErrorDisplay } from "@/components/ui/api-error-display"; import * as userApi from "@/lib/api/user"; import * as savingsApi from "@/lib/api/savings"; @@ -17,7 +19,7 @@ export default function SavingsWithdrawPage() { const [termSeconds, setTermSeconds] = useState("0"); const [amount, setAmount] = useState(""); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); + const { uiError, setApiError, clearError, isSubmitDisabled } = useApiError(); const [success, setSuccess] = useState(""); useEffect(() => { @@ -40,7 +42,7 @@ export default function SavingsWithdrawPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!user.trim() || !amount || parseFloat(amount) <= 0) return; - setError(""); + clearError(); setLoading(true); try { await savingsApi.savingsWithdraw( @@ -53,7 +55,7 @@ export default function SavingsWithdrawPage() { ); setSuccess("Withdrawal submitted."); } catch (e) { - setError(e instanceof Error ? e.message : "Withdraw failed"); + setApiError(e); } finally { setLoading(false); } @@ -73,8 +75,8 @@ export default function SavingsWithdrawPage() {
- {error && ( -

{error}

+ {uiError && ( + )} {success && (

{success}

@@ -129,7 +131,7 @@ export default function SavingsWithdrawPage() {
diff --git a/app/send/page.tsx b/app/send/page.tsx index d274022..a24671b 100644 --- a/app/send/page.tsx +++ b/app/send/page.tsx @@ -27,6 +27,8 @@ import { Tabs, TabsContent, TabsTrigger, TabsList } from "@/components/ui/tabs"; import { SkeletonList } from "@/components/ui/skeleton-list"; import { Plus, Check, AlertCircle, ArrowRight } from "lucide-react"; import { useApiOpts } from "@/hooks/use-api"; +import { useApiError } from "@/hooks/use-api-error"; +import { ApiErrorDisplay } from "@/components/ui/api-error-display"; import { useBalance } from "@/hooks/use-balance"; import { useAuth } from "@/contexts/auth-context"; import * as transfersApi from "@/lib/api/transfers"; @@ -65,6 +67,7 @@ export default function SendPage() { const { userId, stellarAddress } = useAuth(); const kit = useStellarWalletsKit(); const { balance, loading: balanceLoading, refresh: refreshBalance } = useBalance(); + const { uiError, setApiError, clearError, isSubmitDisabled } = useApiError(); const [activeTab, setActiveTab] = useState("send"); const [showSendDialog, setShowSendDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); @@ -81,7 +84,6 @@ export default function SendPage() { const [contacts, setContacts] = useState([]); const [loadingTransfers, setLoadingTransfers] = useState(true); const [loadingContacts, setLoadingContacts] = useState(true); - const [submitError, setSubmitError] = useState(""); const [sending, setSending] = useState(false); const [loadError, setLoadError] = useState(""); @@ -114,7 +116,7 @@ export default function SendPage() { const handleConfirmTransfer = async () => { const to = getToValue(); if (!amount || parseFloat(amount) <= 0 || !to) return; - setSubmitError(""); + clearError(); setSending(true); try { let blockchainTxHash: string | undefined; @@ -189,7 +191,7 @@ export default function SendPage() { setSelectedContact(null); }, 2500); } catch (e) { - setSubmitError(e instanceof Error ? e.message : "Transfer failed"); + setApiError(e); } finally { setSending(false); } @@ -439,8 +441,8 @@ const getStatusColor = (status: string) => {
- {submitError && ( -

{submitError}

+ {uiError && ( + )}

To

@@ -474,7 +476,7 @@ const getStatusColor = (status: string) => {
Cancel - {sending ? 'Sending...' : `Send ACBU ${amount}`} + {sending ? 'Sending...' : `Send ACBU ${amount}`}
diff --git a/components/ui/api-error-display.tsx b/components/ui/api-error-display.tsx new file mode 100644 index 0000000..a1f3fc3 --- /dev/null +++ b/components/ui/api-error-display.tsx @@ -0,0 +1,106 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { AlertCircle, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { UIError } from "@/hooks/use-api-error"; + +interface ApiErrorDisplayProps { + error: UIError; + onDismiss?: () => void; + /** Called when the action button is clicked (in addition to action.onClick). */ + onActionClick?: () => void; + className?: string; +} + +/** + * Renders a mapped UIError with its recovery action button/link. + * Handles the 30-second countdown for rate-limit (429) errors automatically. + */ +export function ApiErrorDisplay({ + error, + onDismiss, + onActionClick, + className, +}: ApiErrorDisplayProps) { + const [secondsLeft, setSecondsLeft] = useState(null); + + useEffect(() => { + if (!error.action?.disableSubmitFor) { + setSecondsLeft(null); + return; + } + const total = Math.ceil(error.action.disableSubmitFor / 1000); + setSecondsLeft(total); + const interval = setInterval(() => { + setSecondsLeft((s) => { + if (s == null || s <= 1) { + clearInterval(interval); + return null; + } + return s - 1; + }); + }, 1000); + return () => clearInterval(interval); + }, [error]); + + const { action } = error; + + return ( +
+
+ ); +} diff --git a/hooks/use-api-error.ts b/hooks/use-api-error.ts new file mode 100644 index 0000000..c8ac340 --- /dev/null +++ b/hooks/use-api-error.ts @@ -0,0 +1,113 @@ +import { useState, useCallback } from "react"; +import type { ApiError } from "@/lib/api/client"; + +/** + * Strict shape of an API error response from the backend. + */ +export interface ApiErrorResponse { + status?: number; + message?: string; + details?: unknown; +} + +/** + * A mapped UI error with a human-readable message and an optional recovery action. + */ +export interface UIError { + message: string; + action?: UIErrorAction; +} + +export interface UIErrorAction { + label: string; + /** If set, render as a link. */ + href?: string; + /** If set, call this when the action button is clicked. */ + onClick?: () => void; + /** If true, the submit button should be disabled until this resolves. */ + disableSubmitFor?: number; // milliseconds +} + +/** + * Maps an API error status code to a user-friendly UIError. + * Returns null for null/undefined input so callers can safely pass any caught value. + */ +export function mapApiError(err: unknown): UIError | null { + if (err == null) return null; + + const status = + (err as ApiError).status ?? + (err as ApiErrorResponse).status ?? + undefined; + + const rawMessage = + err instanceof Error + ? err.message + : typeof (err as ApiErrorResponse).message === "string" + ? (err as ApiErrorResponse).message + : undefined; + + switch (status) { + case 429: + return { + message: "Too many requests. Please wait a moment before trying again.", + action: { + label: "Wait and retry", + disableSubmitFor: 30_000, + }, + }; + case 503: + return { + message: + "Our payment processor is temporarily down. Your funds are safe.", + action: { + label: "Check status page", + href: "https://status.acbu.io", + }, + }; + case 402: + return { + message: "Insufficient balance or payment required.", + action: { + label: "Deposit funds", + href: "/savings/deposit", + }, + }; + default: + return { + message: rawMessage ?? "Something went wrong. Please try again.", + }; + } +} + +/** + * Hook that manages a UIError state derived from raw API errors. + * + * Usage: + * const { uiError, setApiError, clearError, isSubmitDisabled } = useApiError(); + * + * catch (e) { setApiError(e); } + * + * {uiError && } + */ +export function useApiError() { + const [uiError, setUiError] = useState(null); + const [submitDisabledUntil, setSubmitDisabledUntil] = useState(0); + + const setApiError = useCallback((err: unknown) => { + const mapped = mapApiError(err); + setUiError(mapped); + if (mapped?.action?.disableSubmitFor) { + setSubmitDisabledUntil(Date.now() + mapped.action.disableSubmitFor); + } + }, []); + + const clearError = useCallback(() => { + setUiError(null); + setSubmitDisabledUntil(0); + }, []); + + const isSubmitDisabled = submitDisabledUntil > Date.now(); + + return { uiError, setApiError, clearError, isSubmitDisabled }; +} diff --git a/package.json b/package.json index 1096d7d..0519798 100644 --- a/package.json +++ b/package.json @@ -80,5 +80,12 @@ "typescript": "^5", "typescript-eslint": "^8.58.2" }, - "packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b" + "packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b", + "pnpm": { + "peerDependencyRules": { + "allowedVersions": { + "eslint-plugin-react>eslint": "^10" + } + } + } } From a6a6c91cefabb3213aaab389734d628f15dcfc08 Mon Sep 17 00:00:00 2001 From: John Date: Thu, 23 Apr 2026 10:06:42 +0100 Subject: [PATCH 08/36] feat: add savings dashboard with deposit and withdrawal pages --- app/savings/deposit/page.tsx | 65 ++++++++++++++++++++++++++---- app/savings/page.tsx | 38 ++++++++++++++++-- app/savings/withdraw/page.tsx | 75 +++++++++++++++++++++++++++++------ 3 files changed, 153 insertions(+), 25 deletions(-) diff --git a/app/savings/deposit/page.tsx b/app/savings/deposit/page.tsx index 47fd92f..252f92c 100644 --- a/app/savings/deposit/page.tsx +++ b/app/savings/deposit/page.tsx @@ -6,10 +6,31 @@ import { PageContainer } from "@/components/layout/page-container"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, AlertCircle } from "lucide-react"; import { useApiOpts } from "@/hooks/use-api"; import * as userApi from "@/lib/api/user"; import * as savingsApi from "@/lib/api/savings"; +import { resolveRecipient } from "@/lib/api/recipient"; + +/** + * Resolve any user identifier (Stellar address, phone, alias, pay URI) + * through the backend recipient resolver to obtain the canonical pay_uri. + * Falls back to the raw value when the resolver is unavailable so that + * Stellar-format addresses still work offline. + */ +async function resolveUserUri( + raw: string, + opts: Parameters[1], +): Promise { + try { + const resolved = await resolveRecipient(raw, opts); + if (resolved.pay_uri) return resolved.pay_uri; + if (resolved.alias) return resolved.alias; + } catch { + // Resolver unavailable — fall through to raw value. + } + return raw; +} export default function SavingsDepositPage() { const opts = useApiOpts(); @@ -17,22 +38,42 @@ export default function SavingsDepositPage() { const [amount, setAmount] = useState(""); const [termSeconds, setTermSeconds] = useState("0"); const [loading, setLoading] = useState(false); + const [resolving, setResolving] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); useEffect(() => { - userApi.getReceive(opts).then((data) => { + let cancelled = false; + setResolving(true); + setError(""); + + userApi.getReceive(opts).then(async (data) => { const uri = (data.pay_uri ?? data.alias) as string | undefined; - if (uri && typeof uri === 'string') setUser(uri); + if (!uri || typeof uri !== 'string') { + if (!cancelled) setResolving(false); + return; + } + + // Resolve through backend recipient resolver so phone-based IDs, + // aliases, and other non-Stellar identifiers are accepted. + const resolved = await resolveUserUri(uri, opts); + if (!cancelled) setUser(resolved); }).catch((e) => { - console.error(e instanceof Error ? e.message : 'Failed to load receive address'); + if (!cancelled) { + setError(e instanceof Error ? e.message : 'Failed to load receive address'); + } + }).finally(() => { + if (!cancelled) setResolving(false); }); + + return () => { cancelled = true; }; }, [opts.token]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!user.trim() || !amount || parseFloat(amount) <= 0) return; setError(""); + setSuccess(""); setLoading(true); try { await savingsApi.savingsDeposit( @@ -66,7 +107,10 @@ export default function SavingsDepositPage() { {error && ( -

{error}

+
+ +

{error}

+
)} {success && (

{success}

@@ -81,10 +125,15 @@ export default function SavingsDepositPage() { + {resolving && ( +

+ Verifying account identifier… +

+ )}
+ {resolving && ( +

+ Verifying account identifier… +

+ )}