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/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 7e746bf..5829856 100644 --- a/src/app/dashboard/wallets/[id]/payment-links/page.tsx +++ b/src/app/dashboard/wallets/[id]/payment-links/page.tsx @@ -1,6 +1,9 @@ "use client"; -import { use, useCallback, useEffect, useRef, useState } from "react"; +import { errorMessage } from "@/lib/api"; +import { payUrl } from "@/lib/format"; +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"; @@ -24,11 +27,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, }: { @@ -39,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( - e instanceof Error ? e.message : "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); @@ -127,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(e instanceof Error ? e.message : "Could not update the link."); + setActionError(errorMessage(e, "Could not update the link.")); } } @@ -216,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]/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..30d9ef2 100644 --- a/src/app/dashboard/wallets/[id]/transactions/page.tsx +++ b/src/app/dashboard/wallets/[id]/transactions/page.tsx @@ -1,6 +1,8 @@ "use client"; -import { use, useCallback, useEffect, useRef, useMemo, useState } from "react"; +import { errorMessage } from "@/lib/api"; +import { use, useCallback, useMemo, useState } from "react"; +import { useCursorPagination } from "@/lib/useCursorPagination"; import { useAuth } from "@/lib/useAuth"; import { useWallet } from "@/lib/useWallet"; import { @@ -32,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( - e instanceof Error ? e.message : "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 @@ -172,7 +127,7 @@ export default function TransactionsPage({ 0 || nextCursor ? `Page ${pageIndex + 1}` : undefined} + sub={hasPrev || hasNext ? `Page ${page}` : undefined} /> @@ -257,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/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 634a7c6..4a9c713 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"; @@ -24,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"; @@ -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, @@ -342,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); } @@ -398,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); } @@ -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/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 52674a4..dc705a3 100644 --- a/src/components/payment-links/PaymentLinkDetail.tsx +++ b/src/components/payment-links/PaymentLinkDetail.tsx @@ -1,5 +1,7 @@ "use client"; +import { errorMessage } from "@/lib/api"; +import { payUrl } from "@/lib/format"; import { useEffect, useState } from "react"; import { Modal, CopyField } from "@/components/dashboard/Modal"; import { formatStroops } from "@/lib/amount"; @@ -10,11 +12,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, @@ -41,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]); @@ -57,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 e152027..fceec52 100644 --- a/src/components/wallets/WalletModals.tsx +++ b/src/components/wallets/WalletModals.tsx @@ -1,11 +1,12 @@ "use client"; +import { formatCountdownSeconds } from "@/lib/format"; import { useEffect, useState } from "react"; 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"; @@ -64,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 { @@ -233,10 +229,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, @@ -404,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); } @@ -484,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); @@ -504,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 { @@ -532,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; @@ -817,7 +809,7 @@ export function WithdrawModal({ aria-live="polite" > Expires in{" "} - {formatCountdown(secondsLeft)} + {formatCountdownSeconds(secondsLeft)}

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/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")}`; +} 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`; } 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 }, 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, + }; +}