Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 30 additions & 58 deletions src/app/dashboard/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -28,61 +28,33 @@ const CATEGORY_COLOR: Record<string, string> = {

export default function AuditLogsPage() {
const { user, token, loading, logout } = useAuth();
const [logs, setLogs] = useState<AuditLog[] | null>(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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
Expand Down Expand Up @@ -178,12 +150,12 @@ export default function AuditLogsPage() {
</div>

<Pagination
page={pageIndex + 1}
hasPrev={pageIndex > 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 && (
Expand Down
119 changes: 44 additions & 75 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,81 +24,49 @@ const SPONSORSHIP_CONCURRENCY = 5;

export default function DashboardHome() {
const { user, token, loading, logout } = useAuth();
const [wallets, setWallets] = useState<WalletView[] | null>(null);
const [sponsorshipByWalletId, setSponsorshipByWalletId] = useState<
Map<string, SponsorshipConfig | null>
>(new Map());

// Cursor stack for wallet pagination.
const [cursors, setCursors] = useState<(string | null)[]>([null]);
const [pageIndex, setPageIndex] = useState(0);
const [nextCursor, setNextCursor] = useState<string | null>(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<SponsorshipConfig>[] = [];
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<string, SponsorshipConfig | null>();
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<SponsorshipConfig>[] = [];
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<string, SponsorshipConfig | null>();
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 (
Expand Down Expand Up @@ -156,7 +125,7 @@ export default function DashboardHome() {
{loadError ? (
<ErrorState
message="Could not load your wallets."
onRetry={() => loadPage(cursors[pageIndex] ?? null)}
onRetry={refresh}
/>
) : wallets === null ? (
<p className="text-sm text-muted">Loading wallets&#x2026;</p>
Expand All @@ -174,12 +143,12 @@ export default function DashboardHome() {
</div>
)}
<Pagination
page={pageIndex + 1}
hasPrev={pageIndex > 0}
hasNext={nextCursor !== null}
page={page}
hasPrev={hasPrev}
hasNext={hasNext}
loading={loadingPage}
onPrev={goPrev}
onNext={goNext}
onPrev={prev}
onNext={next}
/>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/app/dashboard/sponsorship/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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."));
});
}

Expand Down
5 changes: 3 additions & 2 deletions src/app/dashboard/wallets/[id]/addresses/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
},
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/dashboard/wallets/[id]/api/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/app/dashboard/wallets/[id]/assets/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
}
Expand All @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <PageSpinner />;
Expand Down
Loading