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,
+ };
+}