From 177d8eaba51f53a06e0d3ab972e024517f0e8546 Mon Sep 17 00:00:00 2001 From: williams Date: Mon, 19 Oct 2026 14:26:28 +0100 Subject: [PATCH 1/4] refactor: remove the formatStroopsValue wrapper (#550) Closes #550 Co-Authored-By: Claude Haiku 5.5 --- src/lib/sponsorship.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/lib/sponsorship.ts b/src/lib/sponsorship.ts index 9a2a6cf..38636d5 100644 --- a/src/lib/sponsorship.ts +++ b/src/lib/sponsorship.ts @@ -6,11 +6,6 @@ import { apiFetch, path } from "./api"; import { formatStroops } from "./amount"; import { pageQuery, type PageOpts, type Paginated } from "./pagination"; -/** Shared formatter for stroop values, used by both sponsorship pages. */ -export function formatStroopsValue(stroops: number): string { - return formatStroops(stroops); -} - import type { AuthToken, WalletId } from "./brands"; export type { AuthToken, WalletId }; @@ -87,5 +82,5 @@ export function listSponsoredTransactions( * Stellar fees are tiny (base fee 100 stroops = 0.00001 XLM), so rounding to 2 dp hid them. */ export function stroopsToXlm(stroops: number): string { - return `${formatStroopsValue(stroops)} XLM`; + return `${formatStroops(stroops)} XLM`; } From ea78e3236481bf1ba0e92dc0bca39b2fe2166680 Mon Sep 17 00:00:00 2001 From: williams Date: Mon, 19 Oct 2026 14:26:58 +0100 Subject: [PATCH 2/4] refactor: move payUrl and the countdown helpers into src/lib/format.ts (#549) Each helper is defined once. The two countdowns keep their output: one shows hours, the other shows minutes and seconds only. Closes #549 Co-Authored-By: Claude Haiku 5.5 --- .../wallets/[id]/payment-links/page.tsx | 6 +---- src/app/pay/[slug]/page.tsx | 14 +++--------- .../payment-links/PaymentLinkDetail.tsx | 6 +---- src/components/wallets/WalletModals.tsx | 7 ++---- src/lib/format.ts | 22 +++++++++++++++++++ 5 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 src/lib/format.ts diff --git a/src/app/dashboard/wallets/[id]/payment-links/page.tsx b/src/app/dashboard/wallets/[id]/payment-links/page.tsx index 7e746bf..e70da52 100644 --- a/src/app/dashboard/wallets/[id]/payment-links/page.tsx +++ b/src/app/dashboard/wallets/[id]/payment-links/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { payUrl } from "@/lib/format"; import { use, useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "@/lib/useAuth"; import { useWallet } from "@/lib/useWallet"; @@ -24,11 +25,6 @@ import { usePolling } from "@/lib/usePolling"; import { WalletPageShell } from "@/components/dashboard/WalletPageShell"; export const dynamic = "force-dynamic"; -function payUrl(slug: string): string { - if (typeof window === "undefined") return `/pay/${slug}`; - return `${window.location.origin}/pay/${slug}`; -} - export default function PaymentLinksPage({ params, }: { diff --git a/src/app/pay/[slug]/page.tsx b/src/app/pay/[slug]/page.tsx index 634a7c6..6aa3d96 100644 --- a/src/app/pay/[slug]/page.tsx +++ b/src/app/pay/[slug]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatCountdownMs } from "@/lib/format"; import { use, useEffect, useRef, useState } from "react"; import { Logo } from "@/components/Logo"; import { CopyButton } from "@/components/CopyButton"; @@ -53,15 +54,6 @@ function intentInputsKey(amountUsdcStroops: number, name: string, email: string) } // Formats a millisecond countdown as m:ss (or h:mm:ss past an hour) for the payer. -function formatCountdown(ms: number) { - const total = Math.max(0, Math.floor(ms / 1000)); - const hours = Math.floor(total / 3600); - const minutes = Math.floor((total % 3600) / 60); - const seconds = total % 60; - const pad = (n: number) => String(n).padStart(2, "0"); - return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`; -} - function celebrate() { confetti({ particleCount: 140, @@ -583,8 +575,8 @@ export default function PayPage({ }`} > {remainingMs <= EXPIRY_WARNING_MS - ? `⚠️ Less than ${formatCountdown(remainingMs)} left — complete your payment now or it will expire.` - : `⏳ Time remaining to pay: ${formatCountdown(remainingMs)}`} + ? `⚠️ Less than ${formatCountdownMs(remainingMs)} left — complete your payment now or it will expire.` + : `⏳ Time remaining to pay: ${formatCountdownMs(remainingMs)}`} )} diff --git a/src/components/payment-links/PaymentLinkDetail.tsx b/src/components/payment-links/PaymentLinkDetail.tsx index 52674a4..c5e9b74 100644 --- a/src/components/payment-links/PaymentLinkDetail.tsx +++ b/src/components/payment-links/PaymentLinkDetail.tsx @@ -1,5 +1,6 @@ "use client"; +import { payUrl } from "@/lib/format"; import { useEffect, useState } from "react"; import { Modal, CopyField } from "@/components/dashboard/Modal"; import { formatStroops } from "@/lib/amount"; @@ -10,11 +11,6 @@ import { PayWithOctoSnippet } from "@/components/payment-links/PayWithOctoSnippe import { ExportPaymentLinkPaymentsCsvButton } from "@/components/export/ExportPaymentLinkPaymentsCsvButton"; import { PaymentsTable } from "./PaymentsTable"; -function payUrl(slug: string): string { - if (typeof window === "undefined") return `/pay/${slug}`; - return `${window.location.origin}/pay/${slug}`; -} - export function PaymentLinkDetail({ link, walletId, diff --git a/src/components/wallets/WalletModals.tsx b/src/components/wallets/WalletModals.tsx index e152027..ffc728b 100644 --- a/src/components/wallets/WalletModals.tsx +++ b/src/components/wallets/WalletModals.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatCountdownSeconds } from "@/lib/format"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { OtpInput } from "@/components/auth/OtpInput"; @@ -233,10 +234,6 @@ type WithdrawAsset = { /** Stop accepting the code this long before maxTime so the relay can still reach the network. */ const EXPIRY_MARGIN_MS = 10_000; -function formatCountdown(seconds: number): string { - return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; -} - export function WithdrawModal({ token, walletId, @@ -817,7 +814,7 @@ export function WithdrawModal({ aria-live="polite" > Expires in{" "} - {formatCountdown(secondsLeft)} + {formatCountdownSeconds(secondsLeft)}

diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..19f3467 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,22 @@ +/** Display helpers that more than one page or component needs. */ + +/** Public checkout URL for a payment link slug. Falls back to a path during SSR. */ +export function payUrl(slug: string): string { + if (typeof window === "undefined") return `/pay/${slug}`; + return `${window.location.origin}/pay/${slug}`; +} + +/** Countdown from milliseconds. Shows hours only when there are any. */ +export function formatCountdownMs(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`; +} + +/** Countdown from seconds as minutes and seconds. */ +export function formatCountdownSeconds(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} From 7a201735bc6de6de08acbf9f8806fd19b430fd8b Mon Sep 17 00:00:00 2001 From: williams Date: Mon, 19 Oct 2026 14:28:13 +0100 Subject: [PATCH 3/4] refactor: use errorMessage() instead of inline instanceof checks (#551) Message extraction now goes through errorMessage. errorText covers the two places that pass null to friendlyResultMessage, so the caller's fallback still applies. The ApiError checks that carry meaning (status codes) stay. Closes #551 Co-Authored-By: Claude Haiku 5.5 --- src/app/dashboard/sponsorship/page.tsx | 3 ++- .../dashboard/wallets/[id]/addresses/page.tsx | 5 +++-- src/app/dashboard/wallets/[id]/api/page.tsx | 4 ++-- src/app/dashboard/wallets/[id]/assets/page.tsx | 5 +++-- .../[id]/payment-links/[linkId]/edit/page.tsx | 3 ++- .../wallets/[id]/payment-links/page.tsx | 5 +++-- .../dashboard/wallets/[id]/sponsorship/page.tsx | 6 +++--- .../wallets/[id]/transactions/page.tsx | 3 ++- .../dashboard/wallets/[id]/whitelist/page.tsx | 9 +++++---- src/app/pay/[slug]/page.tsx | 6 +++--- src/components/auth/ForgotPasswordForm.tsx | 8 ++++---- src/components/backup/DownloadBackupButton.tsx | 5 +++-- .../dashboard/SponsoredTransactionsTable.tsx | 5 +++-- src/components/export/ExportCsvButton.tsx | 3 ++- src/components/gas/GasTankProvision.tsx | 6 +++--- .../payment-links/CreatePaymentLinkForm.tsx | 5 +++-- .../payment-links/EditPaymentLinkForm.tsx | 5 +++-- .../payment-links/PaymentLinkDetail.tsx | 5 +++-- src/components/settings/ChangePasswordForm.tsx | 4 ++-- src/components/trustlines/TrustlineDetails.tsx | 6 ++---- .../wallets/ChangeWalletPasswordModal.tsx | 7 ++++--- src/components/wallets/EditWalletDetails.tsx | 3 ++- src/components/wallets/RecoverWalletModal.tsx | 5 +++-- src/components/wallets/WalletModals.tsx | 17 ++++++----------- src/lib/api.ts | 5 +++++ src/lib/useAuth.ts | 6 ++---- 26 files changed, 78 insertions(+), 66 deletions(-) diff --git a/src/app/dashboard/sponsorship/page.tsx b/src/app/dashboard/sponsorship/page.tsx index d66854c..930f76f 100644 --- a/src/app/dashboard/sponsorship/page.tsx +++ b/src/app/dashboard/sponsorship/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useEffect, useState } from "react"; import Link from "next/link"; import { useAuth } from "@/lib/useAuth"; @@ -53,7 +54,7 @@ export default function SponsorshipPage() { .catch((e) => { if (e instanceof DOMException && e.name === "AbortError") return; setRows([]); - setLoadError(e instanceof Error ? e.message : "Could not load sponsorship data."); + setLoadError(errorMessage(e, "Could not load sponsorship data.")); }); } diff --git a/src/app/dashboard/wallets/[id]/addresses/page.tsx b/src/app/dashboard/wallets/[id]/addresses/page.tsx index 99a3e06..7a78155 100644 --- a/src/app/dashboard/wallets/[id]/addresses/page.tsx +++ b/src/app/dashboard/wallets/[id]/addresses/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { use, useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useAuth } from "@/lib/useAuth"; @@ -56,7 +57,7 @@ export default function AddressesPage({ setError(null); }) .catch((e) => { - setError(e instanceof Error ? e.message : "Could not load addresses."); + setError(errorMessage(e, "Could not load addresses.")); }) .finally(() => setRefreshing(false)); }, @@ -93,7 +94,7 @@ export default function AddressesPage({ const addr = await createAddress(token, id, customerRef); setAddresses((prev) => [addr, ...prev]); } catch (e) { - setError(e instanceof Error ? e.message : "Could not generate a new address."); + setError(errorMessage(e, "Could not generate a new address.")); } finally { setCreating(false); } diff --git a/src/app/dashboard/wallets/[id]/api/page.tsx b/src/app/dashboard/wallets/[id]/api/page.tsx index 5bc5188..ddad36c 100644 --- a/src/app/dashboard/wallets/[id]/api/page.tsx +++ b/src/app/dashboard/wallets/[id]/api/page.tsx @@ -11,7 +11,7 @@ import { import { PageSpinner } from "@/components/OctoSpinner"; import { CopyButton } from "@/components/CopyButton"; import { Modal } from "@/components/dashboard/Modal"; -import { ApiError, API_URL } from "@/lib/api"; +import { API_URL, errorMessage } from "@/lib/api"; import { Code } from "@/components/docs/DocsUI"; import { toast } from "sonner"; @@ -64,7 +64,7 @@ export default function DevelopersPage({ setKeyInfo({ wallet_id: id, configured: true, prefix: res.prefix }); } catch (err) { toast.error( - err instanceof ApiError ? err.message : "Failed to generate API key.", + errorMessage(err, "Failed to generate API key."), ); } finally { setGenerating(false); diff --git a/src/app/dashboard/wallets/[id]/assets/page.tsx b/src/app/dashboard/wallets/[id]/assets/page.tsx index 2f9b98f..6e6eff5 100644 --- a/src/app/dashboard/wallets/[id]/assets/page.tsx +++ b/src/app/dashboard/wallets/[id]/assets/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { use, useEffect, useState } from "react"; import { useAuth } from "@/lib/useAuth"; import { useWallet } from "@/lib/useWallet"; @@ -41,7 +42,7 @@ export default function AssetsPage({ setError(null); }) .catch((e) => - setError(e instanceof Error ? e.message : "Could not load balances."), + setError(errorMessage(e, "Could not load balances.")), ) .finally(() => setRefreshing(false)); } @@ -59,7 +60,7 @@ export default function AssetsPage({ setBalances([]); // Without this the page shows "no assets", which looks like an empty wallet rather // than a failed request. - setError(e instanceof Error ? e.message : "Could not load balances."); + setError(errorMessage(e, "Could not load balances.")); }); return () => controller.abort(); }, [token, id]); diff --git a/src/app/dashboard/wallets/[id]/payment-links/[linkId]/edit/page.tsx b/src/app/dashboard/wallets/[id]/payment-links/[linkId]/edit/page.tsx index 4bac227..03b2583 100644 --- a/src/app/dashboard/wallets/[id]/payment-links/[linkId]/edit/page.tsx +++ b/src/app/dashboard/wallets/[id]/payment-links/[linkId]/edit/page.tsx @@ -1,4 +1,5 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { use, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/useAuth"; @@ -30,7 +31,7 @@ export default function EditPaymentLinkPage({ if (!token) return; getPaymentLink(asAuthToken(token), asWalletId(id), linkId) .then(setLink) - .catch((e) => setError(e instanceof Error ? e.message : "Could not load the link.")); + .catch((e) => setError(errorMessage(e, "Could not load the link."))); }, [token, id, linkId]); if (loading || !user) return ; diff --git a/src/app/dashboard/wallets/[id]/payment-links/page.tsx b/src/app/dashboard/wallets/[id]/payment-links/page.tsx index e70da52..88792d7 100644 --- a/src/app/dashboard/wallets/[id]/payment-links/page.tsx +++ b/src/app/dashboard/wallets/[id]/payment-links/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { payUrl } from "@/lib/format"; import { use, useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "@/lib/useAuth"; @@ -66,7 +67,7 @@ export default function PaymentLinksPage({ .catch((e) => { if (!opts?.silent) { setError( - e instanceof Error ? e.message : "Could not load payment links.", + errorMessage(e, "Could not load payment links."), ); } }) @@ -123,7 +124,7 @@ export default function PaymentLinksPage({ const updated = await setPaymentLinkActive(asAuthToken(token), asWalletId(id), link.id, !link.active); setLinks((prev) => prev.map((l) => (l.id === updated.id ? updated : l))); } catch (e) { - setError(e instanceof Error ? e.message : "Could not update the link."); + setError(errorMessage(e, "Could not update the link.")); } } diff --git a/src/app/dashboard/wallets/[id]/sponsorship/page.tsx b/src/app/dashboard/wallets/[id]/sponsorship/page.tsx index abc378a..bdc235a 100644 --- a/src/app/dashboard/wallets/[id]/sponsorship/page.tsx +++ b/src/app/dashboard/wallets/[id]/sponsorship/page.tsx @@ -12,7 +12,7 @@ import { } from "@/lib/sponsorship"; import { asAuthToken, asWalletId } from "@/lib/brands"; import { SponsoredTransactionsTable } from "@/components/dashboard/SponsoredTransactionsTable"; -import { ApiError } from "@/lib/api"; +import { errorMessage } from "@/lib/api"; import { PageSpinner } from "@/components/OctoSpinner"; import { formatStroops } from "@/lib/amount"; import { BudgetMeter } from "@/components/sponsorship/BudgetMeter"; @@ -60,7 +60,7 @@ export default function SponsorshipSettingsPage({ .catch((e) => { if (e instanceof DOMException && e.name === "AbortError") return; // Surface the failure so the user knows the config didn't load. - setError(e instanceof Error ? e.message : "Could not load sponsorship config."); + setError(errorMessage(e, "Could not load sponsorship config.")); }); return () => controller.abort(); }, [token, id]); @@ -102,7 +102,7 @@ export default function SponsorshipSettingsPage({ toast.success("Sponsorship settings saved."); } catch (err) { setError( - err instanceof ApiError ? err.message : "Failed to save settings.", + errorMessage(err, "Failed to save settings."), ); } finally { setSaving(false); diff --git a/src/app/dashboard/wallets/[id]/transactions/page.tsx b/src/app/dashboard/wallets/[id]/transactions/page.tsx index 7a15985..f9e8fda 100644 --- a/src/app/dashboard/wallets/[id]/transactions/page.tsx +++ b/src/app/dashboard/wallets/[id]/transactions/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { use, useCallback, useEffect, useRef, useMemo, useState } from "react"; import { useAuth } from "@/lib/useAuth"; import { useWallet } from "@/lib/useWallet"; @@ -63,7 +64,7 @@ export default function TransactionsPage({ // explicit action failing is. if (!opts?.silent) { setError( - e instanceof Error ? e.message : "Could not load transactions.", + errorMessage(e, "Could not load transactions."), ); } }) diff --git a/src/app/dashboard/wallets/[id]/whitelist/page.tsx b/src/app/dashboard/wallets/[id]/whitelist/page.tsx index f105ae2..0c492a9 100644 --- a/src/app/dashboard/wallets/[id]/whitelist/page.tsx +++ b/src/app/dashboard/wallets/[id]/whitelist/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { use, useCallback, useEffect, useState, type FormEvent } from "react"; import { toast } from "sonner"; import { useAuth } from "@/lib/useAuth"; @@ -72,7 +73,7 @@ export default function WhitelistPage({ } catch (e) { setAllowlistLoadState("error"); setAllowlistLoadError( - e instanceof Error ? e.message : "Could not load the withdrawal allowlist.", + errorMessage(e, "Could not load the withdrawal allowlist."), ); } }, [token, id]); @@ -96,7 +97,7 @@ export default function WhitelistPage({ next ? "Withdrawal allowlist enabled." : "Withdrawal allowlist disabled.", ); } catch (e) { - setError(e instanceof Error ? e.message : "Could not update the allowlist."); + setError(errorMessage(e, "Could not update the allowlist.")); } finally { setToggling(false); } @@ -142,7 +143,7 @@ export default function WhitelistPage({ setAddrError(null); toast.success("Address added to the withdrawal allowlist."); } catch (e) { - setError(e instanceof Error ? e.message : "Could not add that address."); + setError(errorMessage(e, "Could not add that address.")); } finally { setAdding(false); } @@ -163,7 +164,7 @@ export default function WhitelistPage({ setPendingRemoval(null); toast.success("Address removed from the withdrawal allowlist."); } catch (e) { - setError(e instanceof Error ? e.message : "Could not remove that address."); + setError(errorMessage(e, "Could not remove that address.")); } finally { setRemovingId(null); } diff --git a/src/app/pay/[slug]/page.tsx b/src/app/pay/[slug]/page.tsx index 6aa3d96..4a9c713 100644 --- a/src/app/pay/[slug]/page.tsx +++ b/src/app/pay/[slug]/page.tsx @@ -25,7 +25,7 @@ import { OctoSpinner } from "@/components/OctoSpinner"; import { PayerPrivacyNotice } from "@/components/checkout/PayerPrivacyNotice"; import confetti from "canvas-confetti"; import { formatStroops } from "@/lib/amount"; -import { ApiError } from "@/lib/api"; +import { ApiError, errorMessage } from "@/lib/api"; import { friendlyResultMessage } from "@/lib/stellar/resultCodes"; import { isTrustedImageUrl } from "@/lib/isTrustedImageUrl"; import { validateRedirectUrl } from "@/lib/redirectUrl"; @@ -334,7 +334,7 @@ export default function PayPage({ replaceIntent(created, key); setStep("pay"); } catch (err) { - setError(err instanceof Error ? err.message : "Could not start this payment."); + setError(errorMessage(err, "Could not start this payment.")); } finally { setSubmitting(false); } @@ -390,7 +390,7 @@ export default function PayPage({ friendlyResultMessage(result.detail, "The payment could not be confirmed on-chain."), ); } catch (err) { - setError(err instanceof Error ? err.message : "Freighter payment failed."); + setError(errorMessage(err, "Freighter payment failed.")); } finally { setFreighterBusy(false); } diff --git a/src/components/auth/ForgotPasswordForm.tsx b/src/components/auth/ForgotPasswordForm.tsx index 2f2a01d..ba77ddd 100644 --- a/src/components/auth/ForgotPasswordForm.tsx +++ b/src/components/auth/ForgotPasswordForm.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { ApiError } from "@/lib/api"; +import { ApiError, errorMessage } from "@/lib/api"; import { requestPasswordReset, resetPassword, MIN_ACCOUNT_PASSWORD_LENGTH } from "@/lib/passwordApi"; import { assessPassword, MIN_PASSWORD_SCORE } from "@/lib/passwordStrength"; import { PasswordStrengthMeter } from "@/lib/passwordStrength"; @@ -40,7 +40,7 @@ export function ForgotPasswordForm() { } catch (err) { // Only surface non-client errors so the flow never reveals whether the email is registered. if (!(err instanceof ApiError) || err.status >= 500) { - setError(err instanceof Error ? err.message : "Something went wrong."); + setError(errorMessage(err, "Something went wrong.")); setBusy(false); return; } @@ -66,7 +66,7 @@ export function ForgotPasswordForm() { await requestPasswordReset(email.trim()); setCooldown(30); } catch (err) { - setError(err instanceof Error ? err.message : "Could not resend the code."); + setError(errorMessage(err, "Could not resend the code.")); } } @@ -90,7 +90,7 @@ export function ForgotPasswordForm() { await resetPassword(email.trim(), code, password); setStep("done"); } catch (err) { - setError(err instanceof ApiError ? err.message : "Could not reset password."); + setError(errorMessage(err, "Could not reset password.")); // An invalid or expired code is fixed on the code step. if (err instanceof ApiError && err.status < 500) setStep("code"); } finally { diff --git a/src/components/backup/DownloadBackupButton.tsx b/src/components/backup/DownloadBackupButton.tsx index 165d3f3..2716952 100644 --- a/src/components/backup/DownloadBackupButton.tsx +++ b/src/components/backup/DownloadBackupButton.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { getBackup, loadLocalBackup, parseBackup, saveLocalBackup, decryptSeed, fromMnemonic } from "@/lib/sdk"; @@ -35,7 +36,7 @@ export function DownloadBackupButton({ new Blob([JSON.stringify(file, null, 2)], { type: "application/json" }), ); } catch (err) { - toast.error(err instanceof Error ? err.message : "Could not download the backup."); + toast.error(errorMessage(err, "Could not download the backup.")); } finally { setBusy(false); } @@ -74,7 +75,7 @@ export function DownloadBackupButton({ saveLocalBackup(walletId, backup); toast.success("Backup imported. Unlock with your wallet password to use it."); } catch (err) { - toast.error(err instanceof Error ? err.message : "Could not import the backup file."); + toast.error(errorMessage(err, "Could not import the backup file.")); } } diff --git a/src/components/dashboard/SponsoredTransactionsTable.tsx b/src/components/dashboard/SponsoredTransactionsTable.tsx index 897c01a..3455ab4 100644 --- a/src/components/dashboard/SponsoredTransactionsTable.tsx +++ b/src/components/dashboard/SponsoredTransactionsTable.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useEffect, useState } from "react"; import { listSponsoredTransactions, @@ -43,7 +44,7 @@ export function SponsoredTransactionsTable({ .catch((e) => { if (!active) return; setRows([]); - setLoadError(e instanceof Error ? e.message : "Could not load sponsored transactions."); + setLoadError(errorMessage(e, "Could not load sponsored transactions.")); }); return () => { active = false; @@ -61,7 +62,7 @@ export function SponsoredTransactionsTable({ setCursor(page.next_cursor); } catch (e) { // Keep the existing rows and surface the failure so the user can retry. - setMoreError(e instanceof Error ? e.message : "Could not load more transactions."); + setMoreError(errorMessage(e, "Could not load more transactions.")); } finally { setLoadingMore(false); } diff --git a/src/components/export/ExportCsvButton.tsx b/src/components/export/ExportCsvButton.tsx index e6c5d03..b5c3439 100644 --- a/src/components/export/ExportCsvButton.tsx +++ b/src/components/export/ExportCsvButton.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useState } from "react"; import { ActionButton } from "@/components/dashboard/WalletUI"; import { buildCsv, downloadCsv, type CsvColumn } from "@/lib/csv"; @@ -25,7 +26,7 @@ export function ExportCsvButton({ try { downloadCsv(filename, buildCsv(await loadRows(), columns)); } catch (e) { - setError(e instanceof Error ? e.message : "Export failed."); + setError(errorMessage(e, "Export failed.")); } finally { setBusy(false); } diff --git a/src/components/gas/GasTankProvision.tsx b/src/components/gas/GasTankProvision.tsx index 82f49b7..a1c91fa 100644 --- a/src/components/gas/GasTankProvision.tsx +++ b/src/components/gas/GasTankProvision.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { ApiError } from "@/lib/api"; +import { ApiError, errorMessage } from "@/lib/api"; import { createGasTank } from "@/lib/wallets"; import { getGasTank, type GasTankStatus } from "@/lib/gasTankApi"; @@ -25,7 +25,7 @@ export function GasTankProvision({ token, walletId }: { token: string; walletId: if (!active) return; // Only a 404 means no tank; any other failure must not offer to create one. if (err instanceof ApiError && err.status === 404) setStatus(null); - else setLoadError(err instanceof Error ? err.message : "Could not load gas tank."); + else setLoadError(errorMessage(err, "Could not load gas tank.")); }) .finally(() => active && setLoading(false)); return () => { @@ -40,7 +40,7 @@ export function GasTankProvision({ token, walletId }: { token: string; walletId: setStatus({ gas_tank_address: res.gas_tank_address, funded: res.funded }); toast.success("Gas tank created."); } catch (err) { - toast.error(err instanceof ApiError ? err.message : "Failed to create gas tank."); + toast.error(errorMessage(err, "Failed to create gas tank.")); } finally { setCreating(false); } diff --git a/src/components/payment-links/CreatePaymentLinkForm.tsx b/src/components/payment-links/CreatePaymentLinkForm.tsx index 493cea5..3bf1b7c 100644 --- a/src/components/payment-links/CreatePaymentLinkForm.tsx +++ b/src/components/payment-links/CreatePaymentLinkForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useId, useState, type FormEvent } from "react"; import { Modal } from "@/components/dashboard/Modal"; import { ActionButton } from "@/components/dashboard/WalletUI"; @@ -41,7 +42,7 @@ export function CreatePaymentLinkForm({ } catch (err) { e.target.value = ""; setImageFile(null); - setError(err instanceof Error ? err.message : "Image upload failed."); + setError(errorMessage(err, "Image upload failed.")); return; } } @@ -76,7 +77,7 @@ export function CreatePaymentLinkForm({ }); onCreated(link); } catch (err) { - setError(err instanceof Error ? err.message : "Could not create the link."); + setError(errorMessage(err, "Could not create the link.")); } finally { setCreating(false); } diff --git a/src/components/payment-links/EditPaymentLinkForm.tsx b/src/components/payment-links/EditPaymentLinkForm.tsx index 161b87c..4b1c819 100644 --- a/src/components/payment-links/EditPaymentLinkForm.tsx +++ b/src/components/payment-links/EditPaymentLinkForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useId, useState, type FormEvent } from "react"; import { ActionButton } from "@/components/dashboard/WalletUI"; import { asAuthToken, asWalletId } from "@/lib/brands"; @@ -46,7 +47,7 @@ export function EditPaymentLinkForm({ } catch (err) { e.target.value = ""; setImageFile(null); - setError(err instanceof Error ? err.message : "Image upload failed."); + setError(errorMessage(err, "Image upload failed.")); return; } } @@ -74,7 +75,7 @@ export function EditPaymentLinkForm({ }), ); } catch (err) { - setError(err instanceof Error ? err.message : "Could not save the link."); + setError(errorMessage(err, "Could not save the link.")); } finally { setSaving(false); } diff --git a/src/components/payment-links/PaymentLinkDetail.tsx b/src/components/payment-links/PaymentLinkDetail.tsx index c5e9b74..dc705a3 100644 --- a/src/components/payment-links/PaymentLinkDetail.tsx +++ b/src/components/payment-links/PaymentLinkDetail.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { payUrl } from "@/lib/format"; import { useEffect, useState } from "react"; import { Modal, CopyField } from "@/components/dashboard/Modal"; @@ -37,7 +38,7 @@ export function PaymentLinkDetail({ }) .catch((e) => setPaymentsError( - e instanceof Error ? e.message : "Could not load payments.", + errorMessage(e, "Could not load payments."), ), ); }, [token, walletId, link.id]); @@ -53,7 +54,7 @@ export function PaymentLinkDetail({ setPayments((prev) => [...(prev ?? []), ...page.data]); setNextCursor(page.next_cursor); } catch (e) { - setPaymentsError(e instanceof Error ? e.message : "Could not load payments."); + setPaymentsError(errorMessage(e, "Could not load payments.")); } finally { setLoadingMore(false); } diff --git a/src/components/settings/ChangePasswordForm.tsx b/src/components/settings/ChangePasswordForm.tsx index 65afb4a..9a5a8a4 100644 --- a/src/components/settings/ChangePasswordForm.tsx +++ b/src/components/settings/ChangePasswordForm.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; -import { ApiError } from "@/lib/api"; +import { errorMessage } from "@/lib/api"; import { changePassword, MIN_ACCOUNT_PASSWORD_LENGTH } from "@/lib/passwordApi"; import { assessPassword, MIN_PASSWORD_SCORE } from "@/lib/passwordStrength"; import { PasswordStrengthMeter } from "@/lib/passwordStrength"; @@ -40,7 +40,7 @@ export function ChangePasswordForm({ token }: { token: string }) { setConfirm(""); toast.success("Password changed."); } catch (err) { - setError(err instanceof ApiError ? err.message : "Failed to change password."); + setError(errorMessage(err, "Failed to change password.")); } finally { setBusy(false); } diff --git a/src/components/trustlines/TrustlineDetails.tsx b/src/components/trustlines/TrustlineDetails.tsx index a31734b..b4ec141 100644 --- a/src/components/trustlines/TrustlineDetails.tsx +++ b/src/components/trustlines/TrustlineDetails.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; -import { ApiError } from "@/lib/api"; +import { errorText } from "@/lib/api"; import type { Balance } from "@/lib/wallets"; import { DEFAULT_BASE_RESERVE_STROOPS } from "@/lib/stellar/reserve"; import { @@ -63,9 +63,7 @@ export function TrustlineDetails({ } } catch (err) { toast.error( - err instanceof ApiError || err instanceof Error - ? friendlyResultMessage(err.message, "Could not remove the trustline.") - : "Could not remove the trustline.", + friendlyResultMessage(errorText(err), "Could not remove the trustline."), ); } finally { setPassword(""); diff --git a/src/components/wallets/ChangeWalletPasswordModal.tsx b/src/components/wallets/ChangeWalletPasswordModal.tsx index 74f6a99..bbfbc69 100644 --- a/src/components/wallets/ChangeWalletPasswordModal.tsx +++ b/src/components/wallets/ChangeWalletPasswordModal.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useState } from "react"; import { toast } from "sonner"; import { Modal } from "@/components/dashboard/Modal"; @@ -88,7 +89,7 @@ export function ChangeWalletPasswordModal({ throw new Error("Decrypted key does not match this wallet."); } } catch (err) { - const message = err instanceof Error ? err.message : "Decrypted key does not match this wallet."; + const message = errorMessage(err, "Decrypted key does not match this wallet."); if (/does not match this wallet/i.test(message)) { throw err; } @@ -141,7 +142,7 @@ export function ChangeWalletPasswordModal({ throw new Error("Decrypted key does not match this wallet."); } } catch (err) { - const message = err instanceof Error ? err.message : "Decrypted key does not match this wallet."; + const message = errorMessage(err, "Decrypted key does not match this wallet."); if (/does not match this wallet/i.test(message)) { throw err; } @@ -157,7 +158,7 @@ export function ChangeWalletPasswordModal({ setDone(true); toast.success("Wallet password changed successfully."); } catch (err) { - setError(err instanceof Error ? err.message : "Could not change the password."); + setError(errorMessage(err, "Could not change the password.")); } finally { // Wipe the mnemonic and passwords from memory. mnemonic = ""; diff --git a/src/components/wallets/EditWalletDetails.tsx b/src/components/wallets/EditWalletDetails.tsx index d30f331..a930cf4 100644 --- a/src/components/wallets/EditWalletDetails.tsx +++ b/src/components/wallets/EditWalletDetails.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useState } from "react"; import { Modal } from "@/components/dashboard/Modal"; import type { WalletView } from "@/lib/wallets"; @@ -47,7 +48,7 @@ export function EditWalletDetails({ onSaved({ ...(wallet as WalletView), ...updated }); setOpen(false); } catch (err) { - setError(err instanceof Error ? err.message : "Could not update the wallet."); + setError(errorMessage(err, "Could not update the wallet.")); } finally { setSaving(false); } diff --git a/src/components/wallets/RecoverWalletModal.tsx b/src/components/wallets/RecoverWalletModal.tsx index 139fa63..5246af1 100644 --- a/src/components/wallets/RecoverWalletModal.tsx +++ b/src/components/wallets/RecoverWalletModal.tsx @@ -1,5 +1,6 @@ "use client"; +import { errorMessage } from "@/lib/api"; import { useState } from "react"; import { toast } from "sonner"; import { Modal } from "@/components/dashboard/Modal"; @@ -61,7 +62,7 @@ export function RecoverWalletModal({ } setStep("password"); } catch (err) { - setError(err instanceof Error ? err.message : "Invalid recovery phrase."); + setError(errorMessage(err, "Invalid recovery phrase.")); } finally { setBusy(false); } @@ -107,7 +108,7 @@ export function RecoverWalletModal({ saveLocalBackup(walletId, backup, uploaded.backup_version ?? serialized); setStep("done"); } catch (err) { - setError(err instanceof Error ? err.message : "Could not save the backup."); + setError(errorMessage(err, "Could not save the backup.")); toast.error("Recovery failed — please try again."); } finally { // Clear the mnemonic and password from state regardless of outcome. diff --git a/src/components/wallets/WalletModals.tsx b/src/components/wallets/WalletModals.tsx index ffc728b..fceec52 100644 --- a/src/components/wallets/WalletModals.tsx +++ b/src/components/wallets/WalletModals.tsx @@ -6,7 +6,7 @@ import { toast } from "sonner"; import { OtpInput } from "@/components/auth/OtpInput"; import { Modal, CopyField, HighlightedAddress } from "@/components/dashboard/Modal"; import { DepositQrCodes } from "@/components/qr/DepositQrCodes"; -import { ApiError } from "@/lib/api"; +import { errorMessage, errorText } from "@/lib/api"; import { usdcForNetwork, type Address, type Balance } from "@/lib/wallets"; import { explorerTxUrl, network } from "@/lib/network"; import { Transaction, TransactionBuilder, FeeBumpTransaction } from "@stellar/stellar-base"; @@ -65,12 +65,7 @@ export function TrustlineModal({ detail: res.detail ?? null, }); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : err instanceof Error - ? err.message - : "Could not add the trustline."; + const message = errorMessage(err, "Could not add the trustline."); setError(message); toast.error(message); } finally { @@ -401,7 +396,7 @@ export function WithdrawModal({ setReviewed(true); setSubmitting(false); } catch (err) { - const message = err instanceof Error ? err.message : "Failed to validate destination"; + const message = errorMessage(err, "Failed to validate destination"); setError(message); setSubmitting(false); } @@ -481,7 +476,7 @@ export function WithdrawModal({ setPendingXdr(signedXdr); } catch (err) { const message = friendlyResultMessage( - err instanceof Error ? err.message : null, + errorText(err), "Withdrawal failed.", ); setError(message); @@ -501,7 +496,7 @@ export function WithdrawModal({ setNow(Date.now()); toast.success("A new code is on its way."); } catch (err) { - const message = err instanceof ApiError ? err.message : "Could not resend the code."; + const message = errorMessage(err, "Could not resend the code."); setError(message); toast.error(message); } finally { @@ -529,7 +524,7 @@ export function WithdrawModal({ toast.error(friendlyResultMessage(res.detail, "The withdrawal was not confirmed.")); } } catch (err) { - const raw = err instanceof ApiError ? err.message : null; + const raw = errorText(err); if (isExpiredResult(raw)) { setNetworkExpired(true); return; diff --git a/src/lib/api.ts b/src/lib/api.ts index b5feafc..f8c88fb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -26,6 +26,11 @@ export class ApiError extends Error { * Prefers the `ApiError` message (already saned by `apiFetch`), falls back to any * `Error.message`, and lastly to the caller's `fallback` for non-Error throws. */ +/** The message of an error, or null when the value is not an Error. For callers that choose their own fallback. */ +export function errorText(err: unknown): string | null { + return err instanceof Error ? err.message : null; +} + export function errorMessage(err: unknown, fallback: string): string { if (err instanceof ApiError) return err.message; if (err instanceof Error) return err.message; diff --git a/src/lib/useAuth.ts b/src/lib/useAuth.ts index 4446e18..4ee939c 100644 --- a/src/lib/useAuth.ts +++ b/src/lib/useAuth.ts @@ -3,7 +3,7 @@ import { createContext, createElement, useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { useRouter, usePathname } from "next/navigation"; import { toast } from "sonner"; -import { ApiError } from "./api"; +import { ApiError, errorMessage } from "./api"; import { getToken, signOut, @@ -79,9 +79,7 @@ function useAuthSession() { return; } const message = - err instanceof Error - ? err.message - : "We could not verify your session. Check your connection and try again."; + errorMessage(err, "We could not verify your session. Check your connection and try again."); setAuthError(message); toast.error(message, { action: { label: "Retry", onClick: retrySessionCheck }, From fbee73d0644943de4d22d20d5953d597a2fd72e1 Mon Sep 17 00:00:00 2001 From: williams Date: Mon, 19 Oct 2026 14:32:00 +0100 Subject: [PATCH 4/4] refactor: share cursor pagination in useCursorPagination (#548) The dashboard, audit, transactions and payment links pages now use one hook. It drops responses that are no longer the latest request, which fixes the race in each page's own copy. Filters reset to page 1; the audit search keeps its 350 ms debounce. Closes #548 Co-Authored-By: Claude Haiku 5.5 --- src/app/dashboard/audit/page.tsx | 88 +++++-------- src/app/dashboard/page.tsx | 119 +++++++----------- .../wallets/[id]/payment-links/page.tsx | 102 +++++---------- .../wallets/[id]/transactions/page.tsx | 104 +++++---------- src/lib/useCursorPagination.ts | 106 ++++++++++++++++ 5 files changed, 240 insertions(+), 279 deletions(-) create mode 100644 src/lib/useCursorPagination.ts diff --git a/src/app/dashboard/audit/page.tsx b/src/app/dashboard/audit/page.tsx index c588e75..d787ae3 100644 --- a/src/app/dashboard/audit/page.tsx +++ b/src/app/dashboard/audit/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useAuth } from "@/lib/useAuth"; import { listAuditLogsPage, type AuditLog } from "@/lib/audit"; -import { errorMessage } from "@/lib/api"; +import { useCursorPagination } from "@/lib/useCursorPagination"; import { Pagination } from "@/components/dashboard/Pagination"; import { DashboardShell } from "@/components/dashboard/DashboardShell"; import { PageSpinner } from "@/components/OctoSpinner"; @@ -28,61 +28,33 @@ const CATEGORY_COLOR: Record = { export default function AuditLogsPage() { const { user, token, loading, logout } = useAuth(); - const [logs, setLogs] = useState(null); const [category, setCategory] = useState("all"); const [search, setSearch] = useState(""); - - // Cursor stack: index 0 = page 1 (no cursor), each subsequent entry is the next_cursor. - const [cursors, setCursors] = useState<(string | null)[]>([null]); - const [pageIndex, setPageIndex] = useState(0); - const [nextCursor, setNextCursor] = useState(null); - const [error, setError] = useState(null); - const requestId = useRef(0); - - const load = useCallback( - (before: string | null) => { - if (!token) return; - // Only the latest request may update state, so an older slow response is dropped. - const id = ++requestId.current; - setLogs(null); - setError(null); - listAuditLogsPage(token, { category, search, before }) - .then((page) => { - if (id !== requestId.current) return; - setLogs(page.data); - setNextCursor(page.next_cursor); - }) - .catch((err) => { - if (id !== requestId.current) return; - setError(errorMessage(err, "Failed to load audit logs.")); - setLogs([]); - }); - }, - [token, category, search], - ); - + // The search waits 350 ms after typing, and applies at once when cleared. + const [debouncedSearch, setDebouncedSearch] = useState(""); useEffect(() => { - // Reset to page 1 whenever filters change. - setCursors([null]); - setPageIndex(0); - setNextCursor(null); - const t = setTimeout(() => load(null), search ? 350 : 0); + const t = setTimeout(() => setDebouncedSearch(search), search ? 350 : 0); return () => clearTimeout(t); - }, [load, search]); - - function goNext() { - if (!nextCursor) return; - setCursors((c) => [...c.slice(0, pageIndex + 1), nextCursor]); - setPageIndex((i) => i + 1); - load(nextCursor); - } - - function goPrev() { - if (pageIndex === 0) return; - const target = cursors[pageIndex - 1]; - setPageIndex((i) => i - 1); - load(target ?? null); - } + }, [search]); + + const { + rows, + loaded, + loading: logsLoading, + error, + page, + hasPrev, + hasNext, + next, + prev, + } = useCursorPagination( + (before) => listAuditLogsPage(token ?? "", { category, search: debouncedSearch, before }), + [category, debouncedSearch], + "Failed to load audit logs.", + Boolean(token), + ); + // Null while loading, empty after an error, so the list shows the same states as before. + const logs = loaded && !logsLoading ? (error ? [] : rows) : null; if (loading || !user) { return ( @@ -178,12 +150,12 @@ export default function AuditLogsPage() {
0} - hasNext={nextCursor !== null} + page={page} + hasPrev={hasPrev} + hasNext={hasNext} loading={logs === null} - onPrev={goPrev} - onNext={goNext} + onPrev={prev} + onNext={next} /> {logs && logs.length > 0 && ( diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a85e5d4..05c402a 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useId, useRef, useState } from "react"; +import { useCursorPagination } from "@/lib/useCursorPagination"; import Link from "next/link"; import { useAuth } from "@/lib/useAuth"; import { displayName } from "@/lib/auth"; @@ -23,81 +24,49 @@ const SPONSORSHIP_CONCURRENCY = 5; export default function DashboardHome() { const { user, token, loading, logout } = useAuth(); - const [wallets, setWallets] = useState(null); const [sponsorshipByWalletId, setSponsorshipByWalletId] = useState< Map >(new Map()); - // Cursor stack for wallet pagination. - const [cursors, setCursors] = useState<(string | null)[]>([null]); - const [pageIndex, setPageIndex] = useState(0); - const [nextCursor, setNextCursor] = useState(null); - const [loadingPage, setLoadingPage] = useState(false); - const cancelPrevLoad = useRef<(() => void) | null>(null); - const [loadError, setLoadError] = useState(false); - - const loadPage = useCallback( - (before: string | null) => { - if (!token) return; - let aborted = false; - // Cancel the previous request so a slow older page can't overwrite the current one. - cancelPrevLoad.current?.(); - cancelPrevLoad.current = () => { aborted = true; }; - setLoadingPage(true); - setWallets(null); - setLoadError(false); - listWalletsPage(token, { before }) - .then(async (page) => { - if (aborted) return; - setWallets(page.data); - setNextCursor(page.next_cursor); - // Fetch sponsorship configs for the visible page in parallel. - const results: PromiseSettledResult[] = []; - for (let i = 0; i < page.data.length; i += SPONSORSHIP_CONCURRENCY) { - const batch = page.data.slice(i, i + SPONSORSHIP_CONCURRENCY); - results.push( - ...(await Promise.allSettled( - batch.map((w) => getSponsorshipConfig(asAuthToken(token), asWalletId(w.id))), - )), - ); - } - if (aborted) return; - const map = new Map(); - page.data.forEach((w, i) => { - const r = results[i]; - map.set(w.id, r?.status === "fulfilled" ? r.value : null); - }); - setSponsorshipByWalletId(map); - }) - .catch(() => { - if (!aborted) setLoadError(true); - }) - .finally(() => { - if (!aborted) setLoadingPage(false); - }); - return () => { aborted = true; }; + const { + rows: pageWallets, + loaded, + loading: loadingPage, + error: loadErrorMessage, + page, + hasPrev, + hasNext, + refresh, + next, + prev, + } = useCursorPagination( + async (before) => { + const result = await listWalletsPage(token ?? "", { before }); + // Fetch sponsorship configs for the visible page in batches. + const results: PromiseSettledResult[] = []; + for (let i = 0; i < result.data.length; i += SPONSORSHIP_CONCURRENCY) { + const batch = result.data.slice(i, i + SPONSORSHIP_CONCURRENCY); + results.push( + ...(await Promise.allSettled( + batch.map((w) => getSponsorshipConfig(asAuthToken(token ?? ""), asWalletId(w.id))), + )), + ); + } + const map = new Map(); + result.data.forEach((w, i) => { + const r = results[i]; + map.set(w.id, r?.status === "fulfilled" ? r.value : null); + }); + setSponsorshipByWalletId(map); + return result; }, [token], + "Could not load your wallets.", + Boolean(token), ); - - useEffect(() => { - loadPage(null); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]); - - function goNext() { - if (!nextCursor) return; - setCursors((c) => [...c.slice(0, pageIndex + 1), nextCursor]); - setPageIndex((i) => i + 1); - loadPage(nextCursor); - } - - function goPrev() { - if (pageIndex === 0) return; - const target = cursors[pageIndex - 1]; - setPageIndex((i) => i - 1); - loadPage(target ?? null); - } + const loadError = loadErrorMessage !== null; + // Null while a page loads or before the first one arrives, so the list shows its skeleton. + const wallets = loaded && !loadingPage ? pageWallets : null; if (loading || !user) { return ( @@ -156,7 +125,7 @@ export default function DashboardHome() { {loadError ? ( loadPage(cursors[pageIndex] ?? null)} + onRetry={refresh} /> ) : wallets === null ? (

Loading wallets…

@@ -174,12 +143,12 @@ export default function DashboardHome() { )} 0} - hasNext={nextCursor !== null} + page={page} + hasPrev={hasPrev} + hasNext={hasNext} loading={loadingPage} - onPrev={goPrev} - onNext={goNext} + onPrev={prev} + onNext={next} /> diff --git a/src/app/dashboard/wallets/[id]/payment-links/page.tsx b/src/app/dashboard/wallets/[id]/payment-links/page.tsx index 88792d7..5829856 100644 --- a/src/app/dashboard/wallets/[id]/payment-links/page.tsx +++ b/src/app/dashboard/wallets/[id]/payment-links/page.tsx @@ -2,7 +2,8 @@ import { errorMessage } from "@/lib/api"; import { payUrl } from "@/lib/format"; -import { use, useCallback, useEffect, useRef, useState } from "react"; +import { use, useCallback, useState } from "react"; +import { useCursorPagination } from "@/lib/useCursorPagination"; import { useAuth } from "@/lib/useAuth"; import { useWallet } from "@/lib/useWallet"; import { PaymentLinkList } from "@/components/payment-links/PaymentLinkList"; @@ -36,82 +37,41 @@ export default function PaymentLinksPage({ const { wallet } = useWallet(id); const [links, setLinks] = useState([]); - const [refreshing, setRefreshing] = useState(false); const [creating, setCreating] = useState(false); const [showCreate, setShowCreate] = useState(false); const [created, setCreated] = useState(null); const [selected, setSelected] = useState(null); - const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); const [deactivating, setDeactivating] = useState(null); - // Cursor pagination: `cursors[i]` is the `before` cursor for page i+1 (page 1 has none). - const [cursors, setCursors] = useState<(string | null)[]>([null]); - const [pageIndex, setPageIndex] = useState(0); - const [nextCursor, setNextCursor] = useState(null); - - // Latest non-silent request id; a response that no longer matches it is stale and dropped. - const latestReq = useRef(0); - - const load = useCallback( - (before: string | null, opts?: { silent?: boolean }) => { - if (!token) return; - const reqId = opts?.silent ? latestReq.current : ++latestReq.current; - if (!opts?.silent) setRefreshing(true); - return listPaymentLinksPage(asAuthToken(token), asWalletId(id), { before }) - .then((page) => { - if (reqId !== latestReq.current) return; - setLinks(page.data); - setNextCursor(page.next_cursor); - setError(null); - }) - .catch((e) => { - if (!opts?.silent) { - setError( - errorMessage(e, "Could not load payment links."), - ); - } - }) - .finally(() => { - if (!opts?.silent) setRefreshing(false); - }); - }, - [token, id], + const { + rows: links, + setRows: setLinks, + page, + hasPrev, + hasNext, + loading: refreshing, + error: listError, + refresh, + refreshSilently, + next, + prev, + } = useCursorPagination( + (before) => listPaymentLinksPage(asAuthToken(token ?? ""), asWalletId(id), { before }), + [id, token], + "Could not load payment links.", + Boolean(token), ); - - function refresh() { - load(cursors[pageIndex] ?? null); - } - - useEffect(() => { - if (!token) return; - load(null); - }, [token, id, load]); + const error = actionError ?? listError; // Silently re-fetch so a just-received payment (collected total) shows up without a manual // refresh. Page 1 only — refreshing a deeper page would shift rows under the user. - const pollFn = useCallback( - async () => { - if (!token || pageIndex !== 0) return; - await load(null, { silent: true }); - }, - [token, pageIndex, load], - ); + const pollFn = useCallback(async () => { + if (!token || hasPrev) return; + await refreshSilently(); + }, [token, hasPrev, refreshSilently]); usePolling(pollFn, 5000); - function goNext() { - if (!nextCursor) return; - setCursors((c) => [...c.slice(0, pageIndex + 1), nextCursor]); - setPageIndex((i) => i + 1); - load(nextCursor); - } - - function goPrev() { - if (pageIndex === 0) return; - const target = cursors[pageIndex - 1]; - setPageIndex((i) => i - 1); - load(target ?? null); - } - // Deactivating cuts off anyone on the checkout, so it asks first. Reactivating does not. function requestToggle(link: PaymentLink) { if (link.active) setDeactivating(link); @@ -124,7 +84,7 @@ export default function PaymentLinksPage({ const updated = await setPaymentLinkActive(asAuthToken(token), asWalletId(id), link.id, !link.active); setLinks((prev) => prev.map((l) => (l.id === updated.id ? updated : l))); } catch (e) { - setError(errorMessage(e, "Could not update the link.")); + setActionError(errorMessage(e, "Could not update the link.")); } } @@ -213,12 +173,12 @@ export default function PaymentLinksPage({ )} 0} - hasNext={nextCursor !== null} + page={page} + hasPrev={hasPrev} + hasNext={hasNext} loading={refreshing} - onPrev={goPrev} - onNext={goNext} + onPrev={prev} + onNext={next} /> diff --git a/src/app/dashboard/wallets/[id]/transactions/page.tsx b/src/app/dashboard/wallets/[id]/transactions/page.tsx index f9e8fda..30d9ef2 100644 --- a/src/app/dashboard/wallets/[id]/transactions/page.tsx +++ b/src/app/dashboard/wallets/[id]/transactions/page.tsx @@ -1,7 +1,8 @@ "use client"; import { errorMessage } from "@/lib/api"; -import { use, useCallback, useEffect, useRef, useMemo, useState } from "react"; +import { use, useCallback, useMemo, useState } from "react"; +import { useCursorPagination } from "@/lib/useCursorPagination"; import { useAuth } from "@/lib/useAuth"; import { useWallet } from "@/lib/useWallet"; import { @@ -33,83 +34,36 @@ export default function TransactionsPage({ const { user, token, loading } = useAuth(); const { wallet } = useWallet(id); - const [txns, setTxns] = useState([]); - const [refreshing, setRefreshing] = useState(false); const [assetFilter, setAssetFilter] = useState("all"); const [selected, setSelected] = useState(null); - const [error, setError] = useState(null); - // Cursor pagination: `cursors[i]` is the `before` cursor for page i+1 (page 1 has none), so - // Prev is just a pop. The API has no offsets, so pages can only be walked in order. - const [cursors, setCursors] = useState<(string | null)[]>([null]); - const [pageIndex, setPageIndex] = useState(0); - const [nextCursor, setNextCursor] = useState(null); - - // Latest non-silent request id; a response that no longer matches it is stale and dropped. - const latestReq = useRef(0); - - const load = useCallback( - (before: string | null, opts?: { silent?: boolean }) => { - if (!token) return; - const reqId = opts?.silent ? latestReq.current : ++latestReq.current; - if (!opts?.silent) setRefreshing(true); - return listTransactionsPage(token, id, { before }) - .then((page) => { - if (reqId !== latestReq.current) return; - setTxns(page.data); - setNextCursor(page.next_cursor); - setError(null); - }) - .catch((e) => { - // A silent background refresh failing is not worth interrupting the user over; an - // explicit action failing is. - if (!opts?.silent) { - setError( - errorMessage(e, "Could not load transactions."), - ); - } - }) - .finally(() => { - if (!opts?.silent) setRefreshing(false); - }); - }, - [token, id], + const { + rows: txns, + setRows: setTxns, + page, + hasPrev, + hasNext, + loading: refreshing, + error, + refresh, + refreshSilently, + next, + prev, + } = useCursorPagination( + (before) => listTransactionsPage(token ?? "", id, { before }), + [id, token], + "Could not load transactions.", + Boolean(token), ); - function refresh() { - load(cursors[pageIndex] ?? null); - } - - useEffect(() => { - if (!token) return; - load(null); - }, [token, id, load]); - // Silently re-fetch in the background so a deposit (e.g. from a payment link) shows up without // needing a manual refresh. Only page 1 auto-refreshes — re-fetching a deeper page would fight // the user as rows shift underneath them. - const pollFn = useCallback( - async () => { - if (!token || pageIndex !== 0) return; - await load(null, { silent: true }); - }, - [token, pageIndex, load], - ); + const pollFn = useCallback(async () => { + if (!token || hasPrev) return; + await refreshSilently(); + }, [token, hasPrev, refreshSilently]); usePolling(pollFn, 5000); - function goNext() { - if (!nextCursor) return; - setCursors((c) => [...c.slice(0, pageIndex + 1), nextCursor]); - setPageIndex((i) => i + 1); - load(nextCursor); - } - - function goPrev() { - if (pageIndex === 0) return; - const target = cursors[pageIndex - 1]; - setPageIndex((i) => i - 1); - load(target ?? null); - } - // Tabs are per-asset (not per-status or per-op-type, which don't exist on Stellar the way // they do on an EVM chain): "All" plus one tab per distinct `code:issuer` asset this wallet // has ever moved, so a wallet holding several trustlined assets can isolate one asset's @@ -173,7 +127,7 @@ export default function TransactionsPage({ 0 || nextCursor ? `Page ${pageIndex + 1}` : undefined} + sub={hasPrev || hasNext ? `Page ${page}` : undefined} /> @@ -258,12 +212,12 @@ export default function TransactionsPage({ )} 0} - hasNext={nextCursor !== null} + page={page} + hasPrev={hasPrev} + hasNext={hasNext} loading={refreshing} - onPrev={goPrev} - onNext={goNext} + onPrev={prev} + onNext={next} /> diff --git a/src/lib/useCursorPagination.ts b/src/lib/useCursorPagination.ts new file mode 100644 index 0000000..acbf839 --- /dev/null +++ b/src/lib/useCursorPagination.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { errorMessage } from "./api"; +import type { Paginated } from "./pagination"; + +type LoadOptions = { silent?: boolean }; + +/** + * Cursor pagination for a list page. + * + * `cursors[i]` is the `before` cursor for page i + 1 (page 1 has none), so going back + * re-requests the cursor that produced the earlier page. Every request carries an id, and + * a response that is no longer the latest is dropped, so a slow page cannot overwrite a + * newer one. A silent load refreshes the rows without touching the loading or error state. + * + * `fetchPage` is re-created when `deps` change, which resets to page 1. Nothing loads + * while `enabled` is false, for example before the session token is available. + */ +export function useCursorPagination( + fetchPage: (before: string | null) => Promise>, + deps: readonly unknown[], + errorFallback: string, + enabled = true, +) { + const [rows, setRows] = useState([]); + const [pageIndex, setPageIndex] = useState(0); + const [nextCursor, setNextCursor] = useState(null); + const [loading, setLoading] = useState(false); + // False until the first request has settled, so a page can show a skeleton before that. + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(null); + const [cursors, setCursors] = useState<(string | null)[]>([null]); + + const latestRequest = useRef(0); + const fetchRef = useRef(fetchPage); + fetchRef.current = fetchPage; + + const load = useCallback( + (before: string | null, opts?: LoadOptions) => { + const requestId = opts?.silent ? latestRequest.current : ++latestRequest.current; + if (!opts?.silent) setLoading(true); + return fetchRef + .current(before) + .then((page) => { + if (requestId !== latestRequest.current) return; + setRows(page.data); + setNextCursor(page.next_cursor); + setError(null); + }) + .catch((e: unknown) => { + if (requestId !== latestRequest.current || opts?.silent) return; + setError(errorMessage(e, errorFallback)); + }) + .finally(() => { + if (!opts?.silent && requestId === latestRequest.current) setLoading(false); + if (requestId === latestRequest.current) setLoaded(true); + }); + }, + [errorFallback], + ); + + // Reset to page 1 whenever the caller's inputs change (for example the wallet id). + useEffect(() => { + if (!enabled) return; + setCursors([null]); + setPageIndex(0); + load(null); + // The caller decides what counts as a new list through `deps`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [load, enabled, ...deps]); + + const refresh = useCallback(() => load(cursors[pageIndex] ?? null), [load, cursors, pageIndex]); + + const next = useCallback(() => { + if (!nextCursor) return; + setCursors((c) => [...c.slice(0, pageIndex + 1), nextCursor]); + setPageIndex((i) => i + 1); + load(nextCursor); + }, [load, nextCursor, pageIndex]); + + const prev = useCallback(() => { + if (pageIndex === 0) return; + const target = cursors[pageIndex - 1] ?? null; + setPageIndex((i) => i - 1); + load(target); + }, [load, cursors, pageIndex]); + + /** Re-fetch the current page without the loading indicator. */ + const refreshSilently = useCallback(() => load(cursors[pageIndex] ?? null, { silent: true }), [load, cursors, pageIndex]); + + return { + rows, + setRows, + page: pageIndex + 1, + hasPrev: pageIndex > 0, + hasNext: nextCursor !== null, + loading, + loaded, + error, + refresh, + refreshSilently, + next, + prev, + }; +}