diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json
index 377c72b9..036e2f6f 100644
--- a/frontend/.eslintrc.json
+++ b/frontend/.eslintrc.json
@@ -1,5 +1,10 @@
{
"extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
+ "settings": {
+ "next": {
+ "rootDir": "frontend"
+ }
+ },
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"rules": {
diff --git a/frontend/__tests__/PaymentBuilder.test.tsx b/frontend/__tests__/PaymentBuilder.test.tsx
index f677f9df..b0f48f6b 100644
--- a/frontend/__tests__/PaymentBuilder.test.tsx
+++ b/frontend/__tests__/PaymentBuilder.test.tsx
@@ -1,14 +1,28 @@
-import React from "react";
-import { render, screen, fireEvent, act } from "@testing-library/react";
+import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import React from "react";
import PaymentBuilder, { type BuilderRecipient } from "../components/PaymentBuilder";
// Mock framer-motion Reorder and motion components for clean Jest testing
jest.mock("framer-motion", () => {
return {
Reorder: {
- Group: ({ children, axis: _axis, values: _values, onReorder: _onReorder, ...props }: any) =>
{
),
},
motion: {
- button: ({ children, whileHover: _whileHover, whileTap: _whileTap, initial: _initial, animate: _animate, transition: _transition, ...props }: any) => (
-
- ),
- div: ({ children, whileHover: _whileHover, whileTap: _whileTap, initial: _initial, animate: _animate, transition: _transition, ...props }: any) =>
{children}
,
+ button: ({
+ children,
+ whileHover: _whileHover,
+ whileTap: _whileTap,
+ initial: _initial,
+ animate: _animate,
+ transition: _transition,
+ ...props
+ }: any) =>
,
+ div: ({
+ children,
+ whileHover: _whileHover,
+ whileTap: _whileTap,
+ initial: _initial,
+ animate: _animate,
+ transition: _transition,
+ ...props
+ }: any) =>
{children}
,
},
};
});
jest.mock("@/lib/stellar", () => ({
- isValidStellarAddress: jest.fn(
- (addr: string) => addr.startsWith("G") && addr.length === 56
- ),
+ isValidStellarAddress: jest.fn((addr: string) => addr.startsWith("G") && addr.length === 56),
}));
describe("PaymentBuilder", () => {
@@ -72,10 +98,7 @@ describe("PaymentBuilder", () => {
const user = userEvent.setup();
const handleRecipientsChange = jest.fn();
render(
-
+
,
);
const addressInput = screen.getByPlaceholderText("G...");
@@ -151,12 +174,7 @@ describe("PaymentBuilder", () => {
{ id: "2", address: "GBBB", amount: "20", memo: "second", token: { code: "USDC" } },
];
- render(
-
- );
+ render(
);
const dragHandles = screen.getAllByRole("button", { name: /drag to reorder/i });
expect(dragHandles).toHaveLength(2);
@@ -191,12 +209,7 @@ describe("PaymentBuilder", () => {
{ id: "row-1", address: "", amount: "", memo: "", token: { code: "XLM" } },
];
- render(
-
- );
+ render(
);
const item = screen.getByTestId("reorder-item-row-1");
diff --git a/frontend/__tests__/transactionPagination.test.ts b/frontend/__tests__/transactionPagination.test.ts
new file mode 100644
index 00000000..caee0d6f
--- /dev/null
+++ b/frontend/__tests__/transactionPagination.test.ts
@@ -0,0 +1,389 @@
+/**
+ * __tests__/transactionPagination.test.ts
+ * Performance + correctness tests for the virtualized, cursor-paginated
+ * transaction list:
+ *
+ * 1. Cursor pagination must never skip or duplicate rows, even when new
+ * transactions arrive at the head of the dataset between page requests.
+ * 2. The list must only mount the visible window (virtualization), verified
+ * against synthetic datasets of >= 10k rows.
+ * 3. Filters/search keep working across paginated pages.
+ * 4. Window computations and renders stay cheap for large datasets.
+ */
+
+import { render, within } from "@testing-library/react";
+import { createElement } from "react";
+import VirtualizedList, {
+ getVisibleRange,
+ computeScrollOffset,
+} from "@/components/VirtualizedList";
+import {
+ encodeCursor,
+ decodeCursor,
+ readCursorFromQuery,
+ updateCursorInUrl,
+ dedupeById,
+ mergeRecordPages,
+ prependNewestUnique,
+ cursorSlice,
+ CURSOR_QUERY_KEY,
+} from "@/lib/api";
+import { PaymentRecord } from "@/lib/stellar";
+import { searchPayments, parseSearchQuery } from "@/lib/transactionSearch";
+
+// ─── Synthetic dataset helpers ───────────────────────────────────────────────
+
+export type Synthetic = PaymentRecord & {
+ pagingToken: string;
+ /** Legacy field still read by transactionSearch at runtime. */
+ hash: string;
+};
+
+/** Build a newest-first dataset of `count` payments with sequential ids. */
+function buildDataset(count: number, idOffset = 0) {
+ const records: Synthetic[] = [];
+ for (let i = 0; i < count; i++) {
+ // Most recent first, so `i` grows as the history gets older.
+ records.push({
+ id: `op-${idOffset + i}`,
+ type: i % 3 === 0 ? "sent" : "received",
+ amount: `${(i % 400) + 0.5}`,
+ asset: "XLM:GBSTRUSD",
+ from: `GACCOUNTFROM00000000000000000000000000000000000000${i.toString(36)}`,
+ to: `GACCOUNTTO000000000000000000000000000000000000000000${i.toString(36)}`,
+ memo: i % 10 === 0 ? `payroll-${i}` : `memo-${i}`,
+ hash: `hash${i}`.slice(0, 64),
+ createdAt: new Date(2026, 0, 1, 0, 0, Math.max(1, count - i)).toISOString(),
+ transactionHash: `abc123def456ghi789${i.toString(36)}${i}`,
+ pagingToken: `1000000-${count - i}`,
+ });
+ }
+ return records;
+}
+
+/** Extract a `cursor=` param from a URL string (helper for assertions). */
+function readCursorFromUrl(url: string): string {
+ const match = url.match(new RegExp(`[?&]${CURSOR_QUERY_KEY}=([^&]+)`));
+ return match ? decodeURIComponent(match[1]) : "";
+}
+
+// ─── Cursor pagination correctness ───────────────────────────────────────────
+
+describe("cursor pagination — skip & duplicate safety", () => {
+ it("paginates a full dataset with no skipped or duplicated rows", () => {
+ const dataset = buildDataset(100);
+ const limit = 10;
+ const seenIds: string[] = [];
+ let cursor: string | undefined;
+ let pages = 0;
+
+ for (;;) {
+ const page = cursorSlice(dataset, limit, cursor);
+ if (page.records.length === 0) break;
+ seenIds.push(...page.records.map((r) => r.id));
+ pages += 1;
+ if (page.nextCursor == null) break;
+ cursor = page.nextCursor;
+ }
+
+ expect(pages).toBe(10);
+ expect(new Set(seenIds).size).toBe(100);
+ expect(seenIds).toEqual(dataset.map((r) => r.id));
+ });
+
+ it("does not skip or duplicate older rows when new transactions arrive mid-pagination", () => {
+ const dataset = buildDataset(100);
+ const limit = 10;
+
+ const page1 = cursorSlice(dataset, limit);
+ expect(page1.records).toHaveLength(10);
+ const cursorAfterPage1 = page1.nextCursor;
+
+ const page2 = cursorSlice(dataset, limit, cursorAfterPage1);
+ expect(page2.records.map((r) => r.id)).toEqual([
+ "op-10",
+ "op-11",
+ "op-12",
+ "op-13",
+ "op-14",
+ "op-15",
+ "op-16",
+ "op-17",
+ "op-18",
+ "op-19",
+ ]);
+
+ // Five brand-new payments land at the head of the history.
+ const withNewData = [...buildDataset(5), ...dataset];
+ const page3 = cursorSlice(withNewData, limit, page2.nextCursor);
+
+ // Oldest boundary records still advance one clean page — no overlap with
+ // page2 (no duplicates) and nothing older skipped.
+ expect(page3.records.map((r) => r.id)).toEqual([
+ "op-20",
+ "op-21",
+ "op-22",
+ "op-23",
+ "op-24",
+ "op-25",
+ "op-26",
+ "op-27",
+ "op-28",
+ "op-29",
+ ]);
+ expect(page3.nextCursor).toBe(dataset[29].pagingToken);
+ });
+
+ it("mergeRecordPages deduplicates overlapping pages from a shifting backend", () => {
+ const dataset = buildDataset(20);
+ const page1 = cursorSlice(dataset, 10).records;
+ // Simulate Horizon returning a page whose head overlaps page1's tail.
+ const overlapping = [dataset[9], ...dataset.slice(10, 18)];
+ const merged = mergeRecordPages(page1, overlapping);
+ expect(new Set(merged.map((r) => r.id)).size).toBe(merged.length);
+ expect(merged.length).toBe(18);
+ // Order is stable: everything loaded keeps its first-seen position.
+ expect(merged[0].id).toBe(page1[0].id);
+ expect(merged[merged.length - 1].id).toBe(overlapping[overlapping.length - 1].id);
+ });
+
+ it("prependNewestUnique folds fresh records in without duplicating loaded ones", () => {
+ const existing = cursorSlice(buildDataset(50), 20).records;
+ const fresh = buildDataset(5, 100); // brand-new ids that must not collide
+ const merged = prependNewestUnique(existing, fresh);
+ expect(merged.slice(0, 5).map((r) => r.id)).toEqual(fresh.map((r) => r.id));
+ expect(new Set(merged.map((r) => r.id)).size).toBe(merged.length);
+ // Everything previously loaded is still present exactly once.
+ expect(merged.length).toBe(5 + existing.length);
+ });
+
+ it("reacts gracefully when the cursor no longer exists (records trimmed)", () => {
+ const dataset = buildDataset(30);
+ const stale = cursorSlice(dataset, 10, "1000000-999999");
+ expect(stale.records).toHaveLength(0);
+ expect(stale.nextCursor).toBeUndefined();
+ expect(stale.hasMore).toBe(false);
+ });
+
+ it("cursor encoders round-trip and stay URL-safe", () => {
+ const raw = "1234567-890:ABCDEF special/characters=+";
+ const encoded = encodeCursor(raw);
+ expect(encoded).not.toBe(raw);
+ expect(encoded).not.toMatch(/[+/=]/); // base64url alphabet only
+ expect(decodeCursor(encoded)).toBe(raw);
+
+ const url = updateCursorInUrl("/transactions?direction=sent&limit=20", raw);
+ expect(readCursorFromUrl(url)).toBe(encoded);
+ expect(readCursorFromQuery(new URLSearchParams(new URL(url, "http://x").search))).toBe(raw);
+ expect(readCursorFromQuery(url.split("?")[1] ?? "")).toBe(raw);
+ });
+
+ it("updateCursorInUrl removes the cursor without dropping other params", () => {
+ const url = "/transactions?cursor=cGF5bG9hZP0&direction=sent";
+ expect(updateCursorInUrl(url, undefined)).toBe("/transactions?direction=sent");
+ expect(updateCursorInUrl("/transactions", undefined)).toBe("/transactions");
+ });
+
+ it("readCursorFromQuery understands string, URLSearchParams and object sources", () => {
+ const raw = "tob";
+ const encoded = encodeCursor(raw);
+ expect(readCursorFromQuery(`cursor=${encoded}&x=1`)).toBe(raw);
+ expect(readCursorFromQuery(new URLSearchParams(`cursor=${encoded}`))).toBe(raw);
+ expect(readCursorFromQuery({ [CURSOR_QUERY_KEY]: encoded })).toBe(raw);
+ expect(readCursorFromQuery({ [CURSOR_QUERY_KEY]: [encoded] })).toBe(raw);
+ expect(readCursorFromQuery({ foo: "bar" })).toBeUndefined();
+ expect(readCursorFromQuery(null)).toBeUndefined();
+ });
+
+ it("dedupeById is stable and preserves first-seen order", () => {
+ const rows = [
+ { id: "a", v: 1 },
+ { id: "b", v: 2 },
+ { id: "a", v: 3 },
+ { id: "c", v: 4 },
+ ];
+ expect(dedupeById(rows).map((r) => [r.id, r.v])).toEqual([
+ ["a", 1],
+ ["b", 2],
+ ["c", 4],
+ ]);
+ });
+});
+
+// ─── Virtualization: only the visible window is mounted ──────────────────────
+
+describe("virtualized list windowing", () => {
+ const stride = 76 + 8; // itemHeight + rowGap
+
+ it("mounts only a small window for a 10k-row dataset", () => {
+ const dataset = buildDataset(10_000);
+
+ const range = getVisibleRange({
+ total: dataset.length,
+ scrollTop: 0,
+ viewportHeight: 800,
+ itemStride: stride,
+ overscan: 6,
+ });
+ expect(range.startIndex).toBe(0);
+ // Real visible rows (800 / 84 ≈ 10) plus overscan — nowhere near 10k.
+ expect(range.endIndex).toBeLessThan(50);
+
+ const deep = getVisibleRange({
+ total: dataset.length,
+ scrollTop: 8_000 * stride,
+ viewportHeight: 800,
+ itemStride: stride,
+ overscan: 6,
+ });
+ expect(deep.startIndex).toBeGreaterThanOrEqual(7_990);
+ expect(deep.startIndex).toBeLessThanOrEqual(8_000);
+ expect(deep.endIndex - deep.startIndex).toBeLessThan(50);
+ });
+
+ it("renders 10k rows with a bounded DOM size and correct ARIA", () => {
+ const dataset = buildDataset(10_000);
+ const start = performance.now();
+ const { container } = render(
+ createElement(VirtualizedList
as never, {
+ items: dataset as PaymentRecord[],
+ itemHeight: 76,
+ rowGap: 8,
+ listLabel: "Payment history",
+ itemKey: (tx: PaymentRecord) => tx.id,
+ renderItem: (tx: PaymentRecord) => createElement("div", null, tx.id),
+ }),
+ );
+ const renderTime = performance.now() - start;
+
+ const list = within(container as HTMLElement).getByRole("list");
+ expect(list).toHaveAttribute("aria-label", "Payment history");
+
+ const mounted = container.querySelectorAll("[data-virtual-row]");
+ expect(mounted.length).toBeGreaterThan(0);
+ expect(mounted.length).toBeLessThan(100); // window + overscan only
+
+ // Every mounted row advertises its true position in the full dataset.
+ mounted.forEach((row) => {
+ expect(row).toHaveAttribute("aria-setsize", "10000");
+ expect(Number(row.getAttribute("aria-posinset"))).toBeGreaterThan(0);
+ });
+
+ // Polite live region announces the visible range of the full list.
+ const live = container.querySelector("[data-virtualized-live]");
+ expect(live).toHaveAttribute("role", "status");
+ expect(live).toHaveAttribute("aria-live", "polite");
+ expect(live?.textContent).toContain("of 10000");
+
+ // Rendering 10k rows must stay cheap.
+ expect(renderTime).toBeLessThan(3000);
+ });
+
+ it("keeps the focused row mounted so keyboard nav never targets a stale row", () => {
+ const dataset = buildDataset(10_000);
+ const { container } = render(
+ createElement(VirtualizedList as never, {
+ items: dataset as PaymentRecord[],
+ itemHeight: 76,
+ rowGap: 8,
+ listLabel: "Payment history",
+ itemKey: (tx: PaymentRecord) => tx.id,
+ renderItem: (tx: PaymentRecord) => createElement("div", null, tx.id),
+ focusedIndex: 9_000,
+ }),
+ );
+ const mounted = container.querySelectorAll("[data-virtual-row]");
+ expect(mounted.length).toBeLessThan(100);
+ const positions = Array.from(mounted).map((row) => Number(row.getAttribute("aria-posinset")));
+ expect(positions).toContain(9_000);
+ });
+
+ it("viewport scroll offset computation matches browser geometry", () => {
+ // List top scrolled 200px above the viewport top → offset is 200.
+ expect(computeScrollOffset({ viewportScrollY: 500, containerRectTop: -200 })).toBe(200);
+ // List still below the fold → offset clamps to 0.
+ expect(computeScrollOffset({ viewportScrollY: 100, containerRectTop: 400 })).toBe(0);
+ });
+});
+
+// ─── Filters & search across paginated pages ─────────────────────────────────
+
+describe("filters and search across paginated pages", () => {
+ it("search still finds a transaction that lives on a later page", () => {
+ const dataset = buildDataset(500);
+ const page1 = cursorSlice(dataset, 20).records;
+ const page2 = cursorSlice(dataset, 20, "1000000-480").records;
+ const merged = mergeRecordPages(page1, page2);
+
+ // Force a unique candidate later in history so the match only exists once
+ // both pages have been loaded.
+ const target = { ...dataset[50], memo: "waldo-42" };
+ const mergedWithTarget = mergeRecordPages(merged, [target]);
+
+ const results = searchPayments(mergedWithTarget, "waldo-42");
+ expect(results).toHaveLength(1);
+ expect(results[0].payment.id).toBe(target.id);
+ expect(parseSearchQuery("waldo-42").text).toBe("waldo-42");
+ });
+
+ it("operator-based filtering applies across merged pages", () => {
+ const dataset = buildDataset(300);
+ const pages = [cursorSlice(dataset, 20), cursorSlice(dataset, 20, "1000000-280")];
+ const records = pages.reduce(
+ (acc, page) => mergeRecordPages(acc, page.records),
+ [] as PaymentRecord[],
+ );
+
+ // dataset[30] only appears on the second page (records 21..40), so a
+ // match proves operator filters apply across merged cursor pages.
+ const target = dataset[30];
+ const results = searchPayments(records, `amount:${target.amount}`);
+ expect(results).toHaveLength(1);
+ expect(results[0].payment.id).toBe(target.id);
+ results.forEach((r) => {
+ expect(parseFloat(r.payment.amount)).toBe(parseFloat(target.amount));
+ });
+ });
+});
+
+// ─── Performance assertions for large datasets ───────────────────────────────
+
+describe("performance on large synthetic datasets", () => {
+ it("computes windows instantly for 50k+ rows", () => {
+ const dataset = buildDataset(50_000);
+ const start = performance.now();
+ let last = { startIndex: 0, endIndex: 0 };
+ // Simulate a scroll through the whole history.
+ for (let i = 0; i < 500; i++) {
+ last = getVisibleRange({
+ total: dataset.length,
+ scrollTop: i * 5 * 84,
+ viewportHeight: 800,
+ itemStride: 84,
+ overscan: 6,
+ });
+ }
+ const elapsed = performance.now() - start;
+ expect(elapsed).toBeLessThan(500);
+ expect(last.endIndex).toBeLessThanOrEqual(dataset.length);
+ });
+
+ it("is resilient with an empty dataset and degenerate inputs", () => {
+ expect(
+ getVisibleRange({
+ total: 0,
+ scrollTop: 0,
+ viewportHeight: 800,
+ itemStride: 84,
+ overscan: 6,
+ }),
+ ).toEqual({ startIndex: 0, endIndex: 0 });
+ expect(cursorSlice([], 20)).toEqual({
+ records: [],
+ nextCursor: undefined,
+ hasMore: false,
+ });
+ // Limit 0 degrades to a minimum of 1 rather than throwing.
+ expect(cursorSlice(buildDataset(5), 0).records).toHaveLength(1);
+ });
+});
diff --git a/frontend/components/TransactionList.tsx b/frontend/components/TransactionList.tsx
index 17ad66b8..75a08696 100644
--- a/frontend/components/TransactionList.tsx
+++ b/frontend/components/TransactionList.tsx
@@ -8,6 +8,9 @@ import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/router";
import { useState, useEffect, useCallback, useRef, useReducer } from "react";
import { useTranslation } from "react-i18next";
+import HighlightedTransactionRow from "./HighlightedTransactionRow";
+import TransactionSearchBar from "./TransactionSearchBar";
+import VirtualizedList from "./VirtualizedList";
import { withErrorBoundary } from "@/components/ErrorBoundary";
import {
HistoryIcon,
@@ -15,9 +18,9 @@ import {
ArrowDownIcon,
RefreshIcon,
ExternalLinkIcon,
- PrinterIcon,
} from "@/components/icons";
import { useContacts } from "@/hooks/useContacts";
+import { CursorPageInfo, mergeRecordPages } from "@/lib/api";
import { logger } from "@/lib/logger";
import {
getPaymentHistory,
@@ -28,8 +31,15 @@ import {
} from "@/lib/stellar";
import { SearchResult } from "@/lib/transactionSearch";
import { formatAsset, timeAgo, copyToClipboard } from "@/utils/format";
-import HighlightedTransactionRow from "./HighlightedTransactionRow";
-import TransactionSearchBar from "./TransactionSearchBar";
+
+/**
+ * Upper bound on how many payment records are held in memory at once.
+ * Virtualization keeps the DOM tiny; this bounds the array itself so
+ * accounts with massive histories stay memory-light.
+ */
+const MAX_PAGINATED_PAYMENTS = 5000;
+const VIRTUALIZED_ROW_HEIGHT = 76;
+const VIRTUALIZED_ROW_GAP = 8;
/** Shape of the CustomEvent detail for pending transaction lifecycle events. */
interface PendingTxEventDetail {
@@ -62,6 +72,10 @@ interface TransactionListProps {
limit?: number;
compact?: boolean;
filters?: TransactionFilters;
+ /** Cursor anchor from the URL (`?cursor=...`) used to resume pagination. */
+ cursor?: string;
+ /** Called after a page loads so the parent can keep the URL in sync. */
+ onCursorChange?: (info: CursorPageInfo) => void;
/** Called whenever the payments array changes so the parent can access it. */
onPaymentsChange?: (payments: PaymentRecord[]) => void;
/** Called when the user wants to print a receipt for a payment. */
@@ -80,18 +94,19 @@ interface CachedPaymentHistory {
const PAYMENT_HISTORY_CACHE_PREFIX = "finchippay:offline-payments:";
-function getPaymentHistoryCacheKey(publicKey: string, limit: number) {
- return `${PAYMENT_HISTORY_CACHE_PREFIX}${publicKey}:${limit}`;
+function getPaymentHistoryCacheKey(publicKey: string, limit: number, cursor?: string) {
+ return `${PAYMENT_HISTORY_CACHE_PREFIX}${publicKey}:${limit}:${cursor ?? "latest"}`;
}
function loadCachedPaymentHistory(
publicKey: string,
- limit: number
+ limit: number,
+ cursor?: string,
): CachedPaymentHistory | null {
if (typeof window === "undefined") return null;
try {
- const raw = window.localStorage.getItem(getPaymentHistoryCacheKey(publicKey, limit));
+ const raw = window.localStorage.getItem(getPaymentHistoryCacheKey(publicKey, limit, cursor));
if (!raw) return null;
const parsed = JSON.parse(raw) as CachedPaymentHistory;
if (!Array.isArray(parsed.records) || typeof parsed.savedAt !== "number") {
@@ -106,13 +121,14 @@ function loadCachedPaymentHistory(
function savePaymentHistorySnapshot(
publicKey: string,
limit: number,
- snapshot: Omit
+ cursor: string | undefined,
+ snapshot: Omit,
) {
if (typeof window === "undefined") return;
window.localStorage.setItem(
- getPaymentHistoryCacheKey(publicKey, limit),
- JSON.stringify({ ...snapshot, savedAt: Date.now() })
+ getPaymentHistoryCacheKey(publicKey, limit, cursor),
+ JSON.stringify({ ...snapshot, savedAt: Date.now() }),
);
}
@@ -127,22 +143,18 @@ function formatSnapshotTime(savedAt: number) {
export function filterPayments(
payments: PaymentRecord[],
- filters: TransactionFilters
+ filters: TransactionFilters,
): PaymentRecord[] {
- const minimumAmount =
- filters.minAmount.trim() === "" ? null : Number(filters.minAmount);
+ const minimumAmount = filters.minAmount.trim() === "" ? null : Number(filters.minAmount);
const hasMinimumAmount =
minimumAmount !== null && Number.isFinite(minimumAmount) && minimumAmount >= 0;
const memoQuery = filters.memoSearch.trim().toLowerCase();
return payments.filter((payment) => {
- const matchesDirection =
- filters.direction === "all" || payment.type === filters.direction;
- const matchesAmount =
- !hasMinimumAmount || Number(payment.amount) >= (minimumAmount ?? 0);
+ const matchesDirection = filters.direction === "all" || payment.type === filters.direction;
+ const matchesAmount = !hasMinimumAmount || Number(payment.amount) >= (minimumAmount ?? 0);
const matchesMemo =
- !memoQuery ||
- (payment.memo && payment.memo.toLowerCase().includes(memoQuery));
+ !memoQuery || (payment.memo && payment.memo.toLowerCase().includes(memoQuery));
return matchesDirection && matchesAmount && matchesMemo;
});
@@ -153,6 +165,8 @@ function TransactionList({
limit = 20,
compact = false,
filters = { direction: "all", minAmount: "", memoSearch: "" },
+ cursor,
+ onCursorChange,
onPaymentsChange,
onPrintReceipt,
incomingPayment,
@@ -169,27 +183,31 @@ function TransactionList({
const [focusedIndex, setFocusedIndex] = useState(-1);
const [stalePaymentsAt, setStalePaymentsAt] = useState(null);
const [searchResults, setSearchResults] = useState([]);
-
+ const [isAdvanced, setIsAdvanced] = useState(false);
+
type PendingAction =
| { type: "ADD"; payload: PaymentRecord }
| { type: "RESOLVE"; payload: { pendingId: string; resolvedTx: PaymentRecord } }
| { type: "REMOVE"; payload: string }
| { type: "INIT"; payload: PaymentRecord[] };
- const [pendingPayments, dispatchPending] = useReducer((state: PaymentRecord[], action: PendingAction): PaymentRecord[] => {
- switch (action.type) {
- case "ADD":
- return [action.payload, ...state];
- case "RESOLVE":
- return state.filter((tx) => tx.id !== action.payload.pendingId);
- case "REMOVE":
- return state.filter((tx) => tx.id !== action.payload);
- case "INIT":
- return action.payload;
- default:
- return state;
- }
- }, []);
+ const [pendingPayments, dispatchPending] = useReducer(
+ (state: PaymentRecord[], action: PendingAction): PaymentRecord[] => {
+ switch (action.type) {
+ case "ADD":
+ return [action.payload, ...state];
+ case "RESOLVE":
+ return state.filter((tx) => tx.id !== action.payload.pendingId);
+ case "REMOVE":
+ return state.filter((tx) => tx.id !== action.payload);
+ case "INIT":
+ return action.payload;
+ default:
+ return state;
+ }
+ },
+ [],
+ );
useEffect(() => {
try {
@@ -197,17 +215,27 @@ function TransactionList({
dispatchPending({ type: "INIT", payload: stored });
} catch {}
- const onPending = (e: CustomEvent) => dispatchPending({ type: "ADD", payload: e.detail as PaymentRecord });
- const onResolved = (e: CustomEvent) => {
- dispatchPending({ type: "RESOLVE", payload: e.detail });
+ const onPending = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (!detail) return;
+ dispatchPending({ type: "ADD", payload: detail as PaymentRecord });
+ };
+ const onResolved = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (!detail) return;
+ dispatchPending({ type: "RESOLVE", payload: detail });
setPayments((prev) => {
- if (prev.some(p => p.id === e.detail.resolvedTx.id)) return prev;
- const next = [e.detail.resolvedTx, ...prev];
+ if (prev.some((p) => p.id === detail.resolvedTx.id)) return prev;
+ const next = [detail.resolvedTx, ...prev];
onPaymentsChange?.(next);
return next;
});
};
- const onFailed = (e: CustomEvent) => dispatchPending({ type: "REMOVE", payload: e.detail.pendingId });
+ const onFailed = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (!detail) return;
+ dispatchPending({ type: "REMOVE", payload: detail.pendingId });
+ };
window.addEventListener("finchippay:pending-tx", onPending);
window.addEventListener("finchippay:resolved-tx", onResolved);
@@ -229,6 +257,14 @@ function TransactionList({
const router = useRouter();
const lastPagingTokenRef = useRef(undefined);
+ // Cursor the parent has already written to the URL. Used to ignore the echo
+ // of our own `onCursorChange` reports so the URL never triggers refetch loops.
+ const echoCursorRef = useRef(undefined);
+ // Cursor we are currently anchored at (position of the newest loaded page).
+ const anchoredCursorRef = useRef(cursor);
+ // Last cursor the URL sent us. Initialized to the prop so deep links on mount
+ // are handled by the initial fetch instead of triggering a second request.
+ const externalCursorRef = useRef(cursor);
const [infiniteScroll, setInfiniteScroll] = useState(false);
// Sentinel ref for IntersectionObserver — defer initial fetch until visible
@@ -249,7 +285,7 @@ function TransactionList({
observer.disconnect();
}
},
- { rootMargin: "200px" }
+ { rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
@@ -260,75 +296,130 @@ function TransactionList({
setPayments(next);
onPaymentsChange?.(next);
},
- [onPaymentsChange]
+ [onPaymentsChange],
);
const fetchPayments = useCallback(
- async (isLoadMore = false) => {
+ async (isLoadMore = false, navigateCursor?: string) => {
+ const isNavigate = navigateCursor !== undefined;
if (isLoadMore) {
setLoadingMore(true);
} else {
setLoading(true);
- updatePayments([]);
+ if (!isNavigate) {
+ updatePayments([]);
+ }
lastPagingTokenRef.current = undefined;
setHasMore(true);
}
setError(null);
try {
- const cursorToUse = isLoadMore ? lastPagingTokenRef.current : undefined;
- const data: PaymentHistoryResponse = await getPaymentHistory(
- publicKey,
- limit,
- cursorToUse
- );
+ const cursorToUse = isLoadMore
+ ? lastPagingTokenRef.current
+ : isNavigate
+ ? navigateCursor
+ : anchoredCursorRef.current;
+ const data: PaymentHistoryResponse = await getPaymentHistory(publicKey, limit, cursorToUse);
if (isLoadMore) {
setPayments((prev) => {
- const merged = [...prev, ...data.records];
- onPaymentsChange?.(merged);
- savePaymentHistorySnapshot(publicKey, limit, {
- records: merged,
+ const merged = mergeRecordPages(prev, data.records);
+ const trimmed =
+ merged.length > MAX_PAGINATED_PAYMENTS
+ ? merged.slice(0, MAX_PAGINATED_PAYMENTS)
+ : merged;
+ onPaymentsChange?.(trimmed);
+ savePaymentHistorySnapshot(publicKey, limit, cursorToUse, {
+ records: trimmed,
hasMore: data.hasMore,
nextCursor: data.nextCursor,
});
- return merged;
+ return trimmed;
});
} else {
- updatePayments(data.records);
- savePaymentHistorySnapshot(publicKey, limit, {
- records: data.records,
+ const refreshed = isNavigate ? data.records : data.records;
+ updatePayments(refreshed);
+ savePaymentHistorySnapshot(publicKey, limit, cursorToUse, {
+ records: refreshed,
hasMore: data.hasMore,
nextCursor: data.nextCursor,
});
}
setHasMore(data.hasMore);
- const nextToken = data.records[data.records.length - 1]?.pagingToken;
- lastPagingTokenRef.current = nextToken;
+ if (isLoadMore) {
+ lastPagingTokenRef.current = data.records[data.records.length - 1]?.pagingToken;
+ if (cursorToUse) setIsAdvanced(true);
+ } else {
+ anchoredCursorRef.current = cursorToUse;
+ lastPagingTokenRef.current = data.records[data.records.length - 1]?.pagingToken;
+ if (cursorToUse) setIsAdvanced(true);
+ }
setStalePaymentsAt(null);
+ // Report the page so the parent can sync `?cursor=` and reset the
+ // "Newest" affordance. Echo-guarded upstream to avoid refetch loops:
+ // we mirror the cursor that the parent will write back to the URL.
+ echoCursorRef.current = cursorToUse;
+ onCursorChange?.({
+ cursorUsed: cursorToUse,
+ nextCursor: data.nextCursor,
+ hasMore: data.hasMore,
+ });
} catch (err) {
const cached = !isLoadMore
- ? loadCachedPaymentHistory(publicKey, limit)
+ ? loadCachedPaymentHistory(publicKey, limit, navigateCursor ?? anchoredCursorRef.current)
: null;
if (cached) {
updatePayments(cached.records);
setHasMore(cached.hasMore);
lastPagingTokenRef.current = cached.records[cached.records.length - 1]?.pagingToken;
+ anchoredCursorRef.current = navigateCursor ?? anchoredCursorRef.current;
setStalePaymentsAt(cached.savedAt);
setError(null);
+ echoCursorRef.current = anchoredCursorRef.current;
+ onCursorChange?.({
+ cursorUsed: anchoredCursorRef.current,
+ nextCursor: cached.nextCursor,
+ hasMore: cached.hasMore,
+ });
return;
}
setError("Could not load transaction history.");
- logger.error(err);
+ logger.error("Failed to load payment history", {}, err instanceof Error ? err : undefined);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
- [publicKey, limit, updatePayments, onPaymentsChange]
+ [publicKey, limit, updatePayments, onPaymentsChange, onCursorChange],
);
+ // React to external cursor navigation (deep links, browser back/forward,
+ // the "Newest" button). We skip cursors we just reported ourselves, which
+ // prevents the URL-sync → refetch loop.
+ useEffect(() => {
+ if (cursor === externalCursorRef.current) return;
+ if (cursor === echoCursorRef.current) return;
+ externalCursorRef.current = cursor;
+ if (isVisible) {
+ void fetchPayments(false, cursor);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [cursor, isVisible]);
+
+ // Jump back to the newest transaction. Re-anchors the pagination and clears
+ // the URL cursor (via the next onCursorChange report).
+ const goToNewest = useCallback(() => {
+ setFocusedIndex(-1);
+ setIsAdvanced(false);
+ externalCursorRef.current = undefined;
+ echoCursorRef.current = undefined;
+ anchoredCursorRef.current = undefined;
+ lastPagingTokenRef.current = undefined;
+ void fetchPayments(false);
+ }, [fetchPayments]);
+
// IntersectionObserver effect for Infinite Scroll
useEffect(() => {
if (!infiniteScroll || !hasMore || loadingMore || loading) return;
@@ -342,7 +433,7 @@ function TransactionList({
fetchPayments(true);
}
},
- { rootMargin: "200px" }
+ { rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
@@ -365,7 +456,7 @@ function TransactionList({
const existing = contacts.find((contact) => contact.publicKey === address);
const nickname = window.prompt(
existing ? "Update contact nickname:" : "Nickname for this contact:",
- existing?.name || address.slice(0, 8)
+ existing?.name || address.slice(0, 8),
);
if (!nickname) return;
@@ -423,7 +514,9 @@ function TransactionList({
const visiblePayments = filterPayments([...pendingPayments, ...payments], filters);
const hasActiveFilters =
- filters.direction !== "all" || filters.minAmount.trim() !== "" || filters.memoSearch.trim() !== "";
+ filters.direction !== "all" ||
+ filters.minAmount.trim() !== "" ||
+ filters.memoSearch.trim() !== "";
if (loading) {
return (
@@ -436,10 +529,7 @@ function TransactionList({
)}
{Array.from({ length: 5 }).map((_, i) => (
-
+
@@ -461,10 +551,7 @@ function TransactionList({
{error}
-
@@ -480,10 +567,10 @@ function TransactionList({
-
{t("transactions.noTransactions")}
-
- {t("transactions.startMessage")}
+
+ {t("transactions.noTransactions")}
+
{t("transactions.startMessage")}
{process.env.NEXT_PUBLIC_STELLAR_NETWORK !== "mainnet" && (
{t("transactions.needTestXlm")}{" "}
@@ -503,101 +590,119 @@ function TransactionList({
}
return (
-
-
- {!compact && (
-
-
-
- {t("transactions.title")}
-
-
- {/* Premium Infinite Scroll Toggle */}
-
-
fetchPayments()}
- className="text-xs text-slate-600 dark:text-slate-400 hover:text-stellar-700 dark:hover:text-stellar-400 transition-colors flex items-center gap-1"
- >
-
- {t("transactions.refresh")}
-
+ {!compact && (
+
+
+
+ {t("transactions.title")}
+
+
+ {/* Premium Infinite Scroll Toggle */}
+
- )}
+
+
fetchPayments()}
+ className="text-xs text-slate-600 dark:text-slate-400 hover:text-stellar-700 dark:hover:text-stellar-400 transition-colors flex items-center gap-1"
+ >
+
+ {t("transactions.refresh")}
+
+ {isAdvanced && (
+
+
+ {t("transactions.newest")}
+
+ )}
+
+
+ )}
- {stalePaymentsAt && (
-
- Offline history snapshot from {formatSnapshotTime(stalePaymentsAt)}
-
- )}
+ {stalePaymentsAt && (
+
+ Offline history snapshot from {formatSnapshotTime(stalePaymentsAt)}
+
+ )}
- {/* Advanced Transaction Search Bar */}
- {!compact && (
-
-
-
- )}
-
-
-
- {t("transactions.keyboardNav")}
-
+ {/* Advanced Transaction Search Bar */}
+ {!compact && (
+
+
+
+ )}
- {/* Search Results Summary */}
- {searchResults.length > 0 && (
-
-
- Found {searchResults.length} matching transaction{searchResults.length !== 1 ? "s" : ""}
-
-
- )}
-
-
-
- {/* Display search results if available, otherwise display filtered transactions */}
- {searchResults.length > 0
- ? searchResults.map((result) => (
+
+
+ {t("transactions.keyboardNav")}
+
+
+ {/* Search Results Summary */}
+ {searchResults.length > 0 && (
+
+
+ Found {searchResults.length} matching transaction
+ {searchResults.length !== 1 ? "s" : ""}
+
+
+ )}
+
+ {searchResults.length > 0 ? (
+
+
+ {/* Display search results if available */}
+ {searchResults.map((result) => (
- ))
- : visiblePayments.map((tx, index) => (
- {
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- setFocusedIndex((prev) => Math.min(prev + 1, visiblePayments.length - 1));
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- setFocusedIndex((prev) => Math.max(prev - 1, 0));
- } else if (e.key === 'Enter' && focusedIndex === index) {
- e.preventDefault();
- router.push(`/tx/${tx.transactionHash}`);
- }
- }}
- onBlur={() => setFocusedIndex(-1)}
- onFocus={() => setFocusedIndex(index)}
- onClick={() => router.push(`/tx/${tx.transactionHash}`)}
- className={clsx(
- "flex items-center gap-3 p-3 rounded-xl bg-slate-50 rtl:flex-row-reverse dark:bg-white/3 hover:bg-slate-100 dark:hover:bg-white/5 transition-colors group relative cursor-pointer",
- focusedIndex === index && "outline-none ring-2 ring-stellar-500 ring-offset-2"
- )}
- aria-label={`${tx.type === "sent" ? "Sent" : "Received"} ${formatAsset(tx.amount, tx.asset)} ${tx.type === "sent" ? "to" : "from"} ${tx.type === "sent" ? tx.to : tx.from}`}
- >
- {/* Direction icon */}
+ ))}
+
+
+ ) : (
+ tx.id}
+ className="w-full"
+ renderItem={(tx, index) => (
{
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setFocusedIndex((prev) => Math.min(prev + 1, visiblePayments.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
+ } else if (e.key === "Enter" && focusedIndex === index) {
+ e.preventDefault();
+ router.push(`/tx/${tx.transactionHash}`);
+ }
+ }}
+ onBlur={() => setFocusedIndex(-1)}
+ onFocus={() => setFocusedIndex(index)}
+ onClick={() => router.push(`/tx/${tx.transactionHash}`)}
className={clsx(
- "w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0",
- tx.type === "sent"
- ? "bg-red-500/10 border border-red-500/20"
- : "bg-emerald-500/10 border border-emerald-500/20"
+ "flex items-center gap-3 p-3 rounded-xl bg-slate-50 rtl:flex-row-reverse dark:bg-white/3 hover:bg-slate-100 dark:hover:bg-white/5 transition-colors group relative cursor-pointer",
+ focusedIndex === index && "outline-none ring-2 ring-stellar-500 ring-offset-2",
)}
+ aria-label={`${tx.type === "sent" ? "Sent" : "Received"} ${formatAsset(tx.amount, tx.asset)} ${tx.type === "sent" ? "to" : "from"} ${tx.type === "sent" ? tx.to : tx.from}`}
>
- {tx.type === "sent" ? (
-
- ) : (
-
- )}
-
-
- {/* Details */}
-
-
-
- {tx.type === "sent" ? t("transactions.sentTo") : t("transactions.receivedFrom")}
-
- {tx.isPending && (
-
-
- Pending
-
+ {/* Direction icon */}
+
{
- e.stopPropagation();
- handleCopy(
- tx.type === "sent" ? tx.to : tx.from,
- tx.id
- );
- }}
- aria-label={`Copy ${tx.type === "sent" ? "recipient" : "sender"} address`}
- className="address-pill hover:border-stellar-500/40 transition-colors text-xs dark:hover:border-stellar-300/60"
- >
- {copiedId === tx.id
- ? t("transactions.copied")
- : shortenAddress(tx.type === "sent" ? tx.to : tx.from, 5)}
-
-
-
-
- {timeAgo(tx.createdAt)}
-
- {tx.memo && (
-
- · “{tx.memo}”
-
+ >
+ {tx.type === "sent" ? (
+
+ ) : (
+
)}
-
- {/* Amount + link */}
-
-
- {tx.type === "sent" ? "-" : "+"}
- {formatAsset(tx.amount, tx.asset)}
-
+ {/* Details */}
+
+
+
+ {tx.type === "sent" ? t("transactions.sentTo") : t("transactions.receivedFrom")}
+
+ {tx.isPending && (
+
+
+ Pending
+
+ )}
+
{
+ e.stopPropagation();
+ handleCopy(tx.type === "sent" ? tx.to : tx.from, tx.id);
+ }}
+ aria-label={`Copy ${tx.type === "sent" ? "recipient" : "sender"} address`}
+ className="address-pill hover:border-stellar-500/40 transition-colors text-xs dark:hover:border-stellar-300/60"
+ >
+ {copiedId === tx.id
+ ? t("transactions.copied")
+ : shortenAddress(tx.type === "sent" ? tx.to : tx.from, 5)}
+
+
+
+
+ {timeAgo(tx.createdAt)}
+
+ {tx.memo && (
+
+ · “{tx.memo}”
+
+ )}
+
+
-
handleSaveContact(tx.type === "sent" ? tx.to : tx.from)}
- className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-slate-600 dark:text-slate-400 hover:text-stellar-600 dark:hover:text-stellar-300 font-medium whitespace-nowrap"
- title={t("transactions.saveAddressToContacts")}
- aria-label={`Save ${tx.type === "sent" ? "recipient" : "sender"} to contacts`}
- >
- {t("transactions.saveContact")}
-
+ {/* Amount + link */}
+
-
- ))}
-
-
- {/* Infinite Scroll Sentinel / Loading Indicator */}
- {infiniteScroll && (
-
- {loadingMore && (
-
+ )}
+ />
+ )}
+ {infiniteScroll && (
+
+ {loadingMore && (
+
+
+
{t("transactions.loadingMore")}
+
+ )}
+
+ )}
+
+ {/* Load more button (only when NOT using infinite scroll) */}
+ {!infiniteScroll && hasMore && payments.length > 0 && (
+
+
+ {loadingMore ? (
+ <>
- {t("transactions.loadingMore")}
-
+ {t("transactions.loadingMore")}
+ >
+ ) : hasActiveFilters ? (
+ t("transactions.loadMoreResults")
+ ) : (
+ t("transactions.loadMore")
)}
-
- )}
-
- {/* Load more button (only when NOT using infinite scroll) */}
- {!infiniteScroll && hasMore && payments.length > 0 && (
-
-
- {loadingMore ? (
- <>
-
- {t("transactions.loadingMore")}
- >
- ) : (
- hasActiveFilters ? t("transactions.loadMoreResults") : t("transactions.loadMore")
- )}
-
-
- )}
-
+
+
+ )}
);
}
diff --git a/frontend/components/VirtualizedList.tsx b/frontend/components/VirtualizedList.tsx
new file mode 100644
index 00000000..06cda487
--- /dev/null
+++ b/frontend/components/VirtualizedList.tsx
@@ -0,0 +1,257 @@
+/**
+ * components/VirtualizedList.tsx
+ * Lightweight dependency-free virtualizer for long, monotonic lists.
+ *
+ * Only the rows overlapping the viewport (+ overscan) are mounted in the DOM,
+ * so accounts with thousands of transactions stay smooth and memory-light.
+ *
+ * The list participates in normal page scrolling: the component measures its
+ * position against `window` and absolutely positions rows inside a tall
+ * spacer. This preserves the existing page-scroll + infinite-scroll UX.
+ *
+ * Accessibility:
+ * - The list is an ARIA `list` labelled with `listLabel`.
+ * - Every mounted row carries `aria-posinset` / `aria-setsize`, so screen
+ * readers announce the true position within the full list even though its
+ * siblings are not in the DOM.
+ * - A visually hidden `role="status"` (polite `aria-live`) region announces
+ * the visible range and total count so SR users know how much of the list
+ * is shown.
+ * - Keyboard focus: the focused row is always forced into the window, so
+ * roving-tabindex navigation (see parent) never points at an unmounted row.
+ */
+
+import clsx from "clsx";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+export interface VirtualListRange {
+ startIndex: number;
+ endIndex: number;
+}
+
+const DEFAULT_ROW_HEIGHT = 76;
+const DEFAULT_ROW_GAP = 8;
+const DEFAULT_OVERSCAN = 6;
+
+/**
+ * Pure window computation — unit-testable without a DOM. Returns the inclusive
+ * `[startIndex, endIndex)` window of rows that must be mounted.
+ *
+ * `scrollOffset` is the distance (px) from the top of the list to the top of
+ * the viewport; `viewportHeight` is the height of the visible region.
+ */
+export function getVisibleRange(params: {
+ total: number;
+ scrollTop: number;
+ viewportHeight: number;
+ itemStride: number;
+ overscan: number;
+}): VirtualListRange {
+ const { total, scrollTop, viewportHeight, itemStride, overscan } = params;
+ if (total <= 0 || itemStride <= 0) return { startIndex: 0, endIndex: 0 };
+
+ const safeScroll = Math.max(0, scrollTop);
+ const safeViewport = Math.max(0, viewportHeight);
+
+ const startIndex = Math.max(0, Math.floor(safeScroll / itemStride) - overscan);
+ const endIndex = Math.min(total, Math.ceil((safeScroll + safeViewport) / itemStride) + overscan);
+ return {
+ startIndex: Math.min(startIndex, total),
+ endIndex: Math.max(endIndex, startIndex),
+ };
+}
+
+interface VirtualizedListProps
{
+ /** Full, ordered dataset. Only a window of it is rendered. */
+ items: T[];
+ /** Fixed height (px) of a single row. */
+ itemHeight?: number;
+ /** Vertical gap (px) between rows. */
+ rowGap?: number;
+ /** Extra rows to keep mounted above/below the viewport. */
+ overscan?: number;
+ /** ARIA label describing the list contents. */
+ listLabel: string;
+ /** Prefix used in the polite live region announcement. */
+ liveLabelPrefix?: string;
+ className?: string;
+ renderItem: (item: T, index: number) => React.ReactElement;
+ itemKey: (item: T, index: number) => string;
+ /** Keep this row inside the window so it stays focusable/renderable. */
+ focusedIndex?: number;
+ /** Called whenever the mounted window shifts. */
+ onVisibleRangeChange?: (range: VirtualListRange) => void;
+ /** Number of ridgeless rows above the list (header/etc.) to ignore. */
+ topSpacerOffset?: number;
+}
+
+/** Offset reserved for the sticky header so the live region is meaningful. */
+export function computeScrollOffset(params: {
+ viewportScrollY: number;
+ containerRectTop: number;
+}): number {
+ const { viewportScrollY, containerRectTop } = params;
+ // Document position of the list's top edge.
+ const containerDocTop = containerRectTop + viewportScrollY;
+ const posInViewport = viewportScrollY - containerDocTop; // == -containerRectTop
+ return Math.max(0, posInViewport);
+}
+
+export default function VirtualizedList({
+ items,
+ itemHeight = DEFAULT_ROW_HEIGHT,
+ rowGap = DEFAULT_ROW_GAP,
+ overscan = DEFAULT_OVERSCAN,
+ listLabel,
+ liveLabelPrefix = "Transactions",
+ className,
+ renderItem,
+ itemKey,
+ focusedIndex,
+ onVisibleRangeChange,
+}: VirtualizedListProps) {
+ const containerRef = useRef(null);
+ const [viewport, setViewport] = useState<{
+ scrollTop: number;
+ viewportHeight: number;
+ }>(() => ({
+ scrollTop: 0,
+ viewportHeight:
+ typeof window !== "undefined" && window.innerHeight > 0 ? window.innerHeight : 600,
+ }));
+
+ const stride = itemHeight + rowGap;
+ const total = items.length;
+ const totalHeight = Math.max(total * stride - (total > 0 ? rowGap : 0), 0);
+
+ const measure = useCallback(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const rect = el.getBoundingClientRect();
+ const viewportHeight = Math.max(240, typeof window !== "undefined" ? window.innerHeight : 600);
+ const scrollTop = computeScrollOffset({
+ viewportScrollY: window.scrollY,
+ containerRectTop: rect.top,
+ });
+ setViewport({ scrollTop, viewportHeight });
+ }, []);
+
+ useEffect(() => {
+ measure();
+ window.addEventListener("scroll", measure, { passive: true });
+ window.addEventListener("resize", measure);
+ return () => {
+ window.removeEventListener("scroll", measure);
+ window.removeEventListener("resize", measure);
+ };
+ }, [measure]);
+
+ const rawRange = useMemo(
+ () =>
+ getVisibleRange({
+ total,
+ scrollTop: viewport.scrollTop,
+ viewportHeight: viewport.viewportHeight,
+ itemStride: stride,
+ overscan,
+ }),
+ [total, viewport.scrollTop, viewport.viewportHeight, stride, overscan],
+ );
+
+ // Expand the window so the focused row (roving tabindex) stays mounted.
+ const winRange = useMemo(() => {
+ const base = rawRange;
+ if (
+ focusedIndex == null ||
+ focusedIndex < 0 ||
+ total === 0 ||
+ (focusedIndex >= base.startIndex && focusedIndex < base.endIndex)
+ ) {
+ return base;
+ }
+ const extent = Math.max(base.endIndex - base.startIndex, 1);
+ if (focusedIndex < base.startIndex) {
+ return {
+ startIndex: focusedIndex,
+ endIndex: Math.min(total, focusedIndex + extent),
+ };
+ }
+ return {
+ startIndex: Math.max(0, focusedIndex - extent + 1),
+ endIndex: focusedIndex + 1,
+ };
+ }, [rawRange, focusedIndex, total]);
+
+ const visibleIndexes = useMemo(() => {
+ const out: number[] = [];
+ for (let i = winRange.startIndex; i < winRange.endIndex; i++) out.push(i);
+ return out;
+ }, [winRange.startIndex, winRange.endIndex]);
+
+ // Report the mounted window (used for the live region + tests).
+ useEffect(() => {
+ onVisibleRangeChange?.(winRange);
+ }, [winRange, onVisibleRangeChange]);
+
+ // Scroll the focused row into view when keyboard navigation moves it outside
+ // the visible region.
+ useEffect(() => {
+ if (focusedIndex == null || focusedIndex < 0 || typeof window === "undefined") return;
+ const el = containerRef.current;
+ if (!el) return;
+ const containerDocTop = el.getBoundingClientRect().top + window.scrollY;
+ const targetTop = containerDocTop + focusedIndex * stride;
+ const targetBottom = targetTop + itemHeight;
+ const visibleTop = window.scrollY;
+ const visibleBottom = window.scrollY + window.innerHeight;
+ if (targetTop < visibleTop) {
+ window.scrollTo({ top: Math.max(0, targetTop - 16), behavior: "smooth" });
+ } else if (targetBottom > visibleBottom) {
+ window.scrollTo({
+ top: targetBottom - window.innerHeight + 16,
+ behavior: "smooth",
+ });
+ }
+ }, [focusedIndex, stride, itemHeight]);
+
+ const liveMessage =
+ total === 0
+ ? `${liveLabelPrefix}: none.`
+ : `${liveLabelPrefix} ${winRange.startIndex + 1} through ${winRange.endIndex} of ${total}.`;
+
+ return (
+
+ {visibleIndexes.map((index) => {
+ const item = items[index];
+ return (
+
+ {renderItem(item, index)}
+
+ );
+ })}
+
+ {liveMessage}
+
+
+ );
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 448be300..bb6d378d 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -82,3 +82,182 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
}
}
})();
+// ─── Cursor pagination ───────────────────────────────────────────────────────
+
+/**
+ * Query-string key used to carry the pagination cursor in the URL, e.g.
+ * `/transactions?cursor=`.
+ */
+export const CURSOR_QUERY_KEY = "cursor";
+
+/**
+ * Meta information about a single cursor page load. The page layer uses this to
+ * keep the URL (`?cursor=...`) in sync so reloads and deep links resume at the
+ * exact same position instead of skipping or duplicating rows.
+ */
+export interface CursorPageInfo {
+ /** The cursor that was used to load the currently displayed page. */
+ cursorUsed?: string;
+ /** The cursor that must be passed to the next call to load older records. */
+ nextCursor?: string;
+ /** Whether older records still exist after this page. */
+ hasMore: boolean;
+}
+
+/**
+ * Encode a cursor so it is safe to embed in a URL query string. Horizon paging
+ * tokens are already URL-safe in practice, but defensively base64url-encode so
+ * custom cursors with reserved characters cannot break the query parsing.
+ */
+export function encodeCursor(cursor: string): string {
+ const clean = cursor.trim();
+ if (!clean) return clean;
+
+ try {
+ const bytes = new TextEncoder().encode(clean);
+ let binary = "";
+ bytes.forEach((byte) => (binary += String.fromCharCode(byte)));
+ if (typeof btoa === "function") {
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+ }
+ } catch {
+ // Fall through to the identity path below for wide strings.
+ }
+ return clean;
+}
+
+/** Inverse of {@link encodeCursor}. Passes through plain tokens unchanged. */
+export function decodeCursor(encoded: string): string {
+ const clean = encoded.trim();
+ if (!clean) return clean;
+
+ try {
+ if (typeof atob === "function") {
+ const b64 = clean.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
+ const binary = atob(padded);
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
+ return new TextDecoder().decode(bytes);
+ }
+ } catch {
+ // Not a base64url payload; treat it as a plain token.
+ }
+ return clean;
+}
+
+/**
+ * Pull the pagination cursor out of a query-string source.
+ *
+ * @example
+ * readCursorFromQuery(new URLSearchParams("cursor=abc&x=1")); // => "abc"
+ */
+export function readCursorFromQuery(
+ search: string | URLSearchParams | Record | null | undefined,
+): string | undefined {
+ let raw: string | undefined;
+ if (typeof search === "string") {
+ raw = new URLSearchParams(search).get(CURSOR_QUERY_KEY) ?? undefined;
+ } else if (search instanceof URLSearchParams) {
+ raw = search.get(CURSOR_QUERY_KEY) ?? undefined;
+ } else if (search && typeof search === "object") {
+ const value = (search as Record)[CURSOR_QUERY_KEY];
+ raw = Array.isArray(value) ? (value[0] as string) : (value as string);
+ }
+ if (raw == null || raw === "") return undefined;
+ return decodeCursor(raw);
+}
+
+/**
+ * Return `url` with the `cursor` query parameter updated to `cursor`.
+ * Passing `undefined` removes the parameter entirely and preserves every other
+ * query parameter, which is what keeps filters/search URL-safe.
+ */
+export function updateCursorInUrl(url: string, cursor: string | undefined): string {
+ const [base = "", search = ""] = url.split("?");
+ const params = new URLSearchParams(search);
+ if (cursor == null || cursor === "") {
+ params.delete(CURSOR_QUERY_KEY);
+ } else {
+ params.set(CURSOR_QUERY_KEY, encodeCursor(cursor));
+ }
+ const qs = params.toString();
+ return qs.length > 0 ? `${base}?${qs}` : base;
+}
+
+/** Remove records that share the same `id`, preserving first-seen order. */
+export function dedupeById(rows: T[]): T[] {
+ const seen = new Set();
+ const result: T[] = [];
+ for (const row of rows) {
+ if (seen.has(row.id)) continue;
+ seen.add(row.id);
+ result.push(row);
+ }
+ return result;
+}
+
+/**
+ * Merge an incoming (older) cursor page into the currently loaded records.
+ *
+ * Pages arrive newest-first and strictly older than the previous page, so a
+ * simple append is correct; `dedupeById` guards against the (rare) Horizon
+ * cases where a paging token moves underneath us so we never render a
+ * transaction twice.
+ */
+export function mergeRecordPages(current: T[], incoming: T[]): T[] {
+ return dedupeById([...current, ...incoming]);
+}
+
+/**
+ * Reconcile a freshly fetched (newest) page with the records already loaded.
+ * New transactions that arrived while paginating are prepended without
+ * disturbing the cursor-anchored pages below them.
+ */
+export function prependNewestUnique(existing: T[], fresh: T[]): T[] {
+ return dedupeById([...fresh, ...existing]);
+}
+
+export interface CursorSlice {
+ records: T[];
+ nextCursor?: string;
+ hasMore: boolean;
+}
+
+/**
+ * Deterministically slice a newest-first dataset using a cursor.
+ *
+ * The cursor is resolved against `pagingToken` values rather than array
+ * offsets, which is what makes the pagination stable when new transactions are
+ * inserted at the front of the dataset between page requests — pages neither
+ * skip nor repeat rows.
+ */
+export function cursorSlice(
+ records: T[],
+ limit: number,
+ cursor?: string,
+): CursorSlice {
+ const safeLimit = Math.max(1, Math.floor(limit) || 1);
+ if (records.length === 0) {
+ return { records: [], nextCursor: undefined, hasMore: false };
+ }
+
+ let startIndex = 0;
+ if (cursor != null && cursor !== "") {
+ const anchorIndex = records.findIndex((r) => r.pagingToken === cursor);
+ // Cursor resolved past the end (oldest records trimmed) => no more records.
+ if (anchorIndex === -1) {
+ return { records: [], nextCursor: undefined, hasMore: false };
+ }
+ startIndex = anchorIndex + 1;
+ }
+
+ const endIndex = Math.min(records.length, startIndex + safeLimit);
+ const page = records.slice(startIndex, endIndex);
+ const hasMore = endIndex < records.length;
+ const last = page[page.length - 1];
+ return {
+ records: page,
+ nextCursor: hasMore ? last.pagingToken : undefined,
+ hasMore,
+ };
+}
diff --git a/frontend/pages/transactions.tsx b/frontend/pages/transactions.tsx
index 291a4682..78a106df 100644
--- a/frontend/pages/transactions.tsx
+++ b/frontend/pages/transactions.tsx
@@ -6,6 +6,7 @@
import Head from "next/head";
import Link from "next/link";
+import { useRouter } from "next/router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import TransactionList, {
filterPayments,
@@ -13,9 +14,17 @@ import TransactionList, {
TransactionFilters,
} from "@/components/TransactionList";
import WalletConnect from "@/components/WalletConnect";
+import { CursorPageInfo, readCursorFromQuery, updateCursorInUrl } from "@/lib/api";
+import { logger } from "@/lib/logger";
import { NETWORK, shortenAddress, PaymentRecord } from "@/lib/stellar";
import { useWallet } from "@/lib/useWallet";
-import { generateCSV, downloadCSV, generatePDF, downloadPDF, type ExportFormat } from "@/utils/export";
+import {
+ generateCSV,
+ downloadCSV,
+ generatePDF,
+ downloadPDF,
+ type ExportFormat,
+} from "@/utils/export";
import { formatAsset, formatDate } from "@/utils/format";
const TRANSACTION_FILTERS_STORAGE_KEY = "finchippay:transaction-filters";
@@ -35,30 +44,57 @@ function isDirectionFilter(value: unknown): value is TransactionDirectionFilter
export default function Transactions() {
const { publicKey } = useWallet();
+ const router = useRouter();
const [payments, setPayments] = useState([]);
const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState(null);
const [exportDropdownOpen, setExportDropdownOpen] = useState(false);
const exportDropdownRef = useRef(null);
- const [directionFilter, setDirectionFilter] =
- useState("all");
+ const [directionFilter, setDirectionFilter] = useState("all");
const [minimumAmount, setMinimumAmount] = useState("");
const [memoSearch, setMemoSearch] = useState("");
const [filtersReady, setFiltersReady] = useState(false);
const [receiptPayment, setReceiptPayment] = useState(null);
+ // Cursor-based pagination state. The URL (`?cursor=...`) is the single source
+ // of truth for the position anchor, so deep links and browser back/forward
+ // resume at the exact same window without skipping or duplicating rows.
+ const cursorParam = useMemo(() => readCursorFromQuery(router.query), [router.query]);
+ const appliedCursorRef = useRef(undefined);
+
+ const syncCursorInUrl = useCallback(
+ (next: string | undefined) => {
+ if (next === appliedCursorRef.current) return;
+ appliedCursorRef.current = next;
+ const url = updateCursorInUrl(router.asPath, next);
+ if (url !== router.asPath) {
+ void router.replace(url, undefined, { scroll: false });
+ }
+ },
+ [router],
+ );
+
+ const handleCursorChange = useCallback(
+ (info: CursorPageInfo) => {
+ // The URL anchor is the cursor that produced the currently displayed
+ // page (`cursorUsed`), never the `nextCursor`, so reloads stay put.
+ syncCursorInUrl(info.cursorUsed);
+ },
+ [syncCursorInUrl],
+ );
+
const transactionFilters = useMemo(
() => ({
direction: directionFilter,
minAmount: minimumAmount,
memoSearch: memoSearch,
}),
- [directionFilter, minimumAmount, memoSearch]
+ [directionFilter, minimumAmount, memoSearch],
);
const filteredPayments = useMemo(
() => filterPayments(payments, transactionFilters),
- [payments, transactionFilters]
+ [payments, transactionFilters],
);
const activeFilterCount =
@@ -103,10 +139,7 @@ export default function Transactions() {
if (!filtersReady) return;
try {
- sessionStorage.setItem(
- TRANSACTION_FILTERS_STORAGE_KEY,
- JSON.stringify(transactionFilters)
- );
+ sessionStorage.setItem(TRANSACTION_FILTERS_STORAGE_KEY, JSON.stringify(transactionFilters));
} catch {
// Session persistence is a convenience; filtering should continue without it.
}
@@ -156,7 +189,7 @@ export default function Transactions() {
downloadPDF(blob, dateStamp);
}
} catch (err) {
- logger.error(`Failed to export ${format}:`, err);
+ logger.error(`Failed to export ${format}`, {}, err instanceof Error ? err : undefined);
} finally {
setExporting(false);
setExportFormat(null);
@@ -227,8 +260,10 @@ export default function Transactions() {
Transaction History | Finchippay-Solution
-
-
+
{/* Header */}
@@ -239,7 +274,9 @@ export default function Transactions() {
{`Account:`}
{/* Added select-text and cursor-text so the address pill remains functional */}
- {shortenAddress(publicKey)}
+
+ {shortenAddress(publicKey)}
+
@@ -292,7 +329,11 @@ export default function Transactions() {
strokeWidth={2}
aria-hidden="true"
>
-
+
>
)}
@@ -306,12 +347,24 @@ export default function Transactions() {
onClick={() => void handleExport("csv")}
className="flex items-center gap-3 w-full px-4 py-3 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors cursor-pointer"
>
-
@@ -461,6 +526,8 @@ export default function Transactions() {
Finchippay Solution
-
- Payment Receipt
-
+ Payment Receipt
{formatDate(receiptPayment.createdAt)}
@@ -563,10 +628,20 @@ export default function Transactions() {
);
}
-function ReceiptRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
+function ReceiptRow({
+ label,
+ value,
+ mono = false,
+}: {
+ label: string;
+ value: string;
+ mono?: boolean;
+}) {
return (
-
{label}
+
+ {label}
+
{value}
diff --git a/frontend/public/locales/ar/common.json b/frontend/public/locales/ar/common.json
index ead20ba9..09ef7d49 100644
--- a/frontend/public/locales/ar/common.json
+++ b/frontend/public/locales/ar/common.json
@@ -316,6 +316,7 @@
"loadingMore": "جارٍ تحميل المزيد...",
"needTestXlm": "تحتاج XLM تجريبي؟",
"noTransactions": "لا توجد معاملات بعد",
+ "newest": "الأحدث",
"offlineSnapshot": "لقطة سجل دون اتصال من",
"paymentHistory": "سجل المدفوعات",
"prefillSendForm": "تعبئة نموذج الإرسال بهذه المعاملة",
diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json
index 6f97643b..858f470f 100644
--- a/frontend/public/locales/en/common.json
+++ b/frontend/public/locales/en/common.json
@@ -316,6 +316,7 @@
"loadingMore": "Loading more...",
"needTestXlm": "Need test XLM?",
"noTransactions": "No transactions yet",
+ "newest": "Newest",
"offlineSnapshot": "Offline history snapshot from",
"paymentHistory": "Payment history",
"prefillSendForm": "Pre-fill send form with this transaction",
diff --git a/frontend/public/locales/es/common.json b/frontend/public/locales/es/common.json
index c7927643..a0881940 100644
--- a/frontend/public/locales/es/common.json
+++ b/frontend/public/locales/es/common.json
@@ -316,6 +316,7 @@
"loadingMore": "Cargando más...",
"needTestXlm": "¿Necesitas XLM de prueba?",
"noTransactions": "Sin transacciones aún",
+ "newest": "Más reciente",
"offlineSnapshot": "Captura de historial sin conexión de",
"paymentHistory": "Historial de pagos",
"prefillSendForm": "Pre-llenar formulario de envío con esta transacción",
diff --git a/frontend/public/locales/fr/common.json b/frontend/public/locales/fr/common.json
index 74687ecc..6f84c785 100644
--- a/frontend/public/locales/fr/common.json
+++ b/frontend/public/locales/fr/common.json
@@ -316,6 +316,7 @@
"loadingMore": "Chargement...",
"needTestXlm": "Besoin de XLM de test ?",
"noTransactions": "Aucune transaction pour le moment",
+ "newest": "Plus récent",
"offlineSnapshot": "Instantané de l'historique hors ligne du",
"paymentHistory": "Historique des paiements",
"prefillSendForm": "Pré-remplir le formulaire d'envoi avec cette transaction",
diff --git a/frontend/public/locales/he/common.json b/frontend/public/locales/he/common.json
index c78f1ec3..f9dd275d 100644
--- a/frontend/public/locales/he/common.json
+++ b/frontend/public/locales/he/common.json
@@ -316,6 +316,7 @@
"loadingMore": "טוען עוד...",
"needTestXlm": "צריך XLM לבדיקה?",
"noTransactions": "אין עדיין עסקאות",
+ "newest": "החדש ביותר",
"offlineSnapshot": "תצוגת היסטוריה לא מקוונת מתאריך",
"paymentHistory": "היסטוריית תשלומים",
"prefillSendForm": "מילוי טופס השליחה מראש עם עסקה זו",
diff --git a/frontend/public/locales/ja/common.json b/frontend/public/locales/ja/common.json
index 3c0eccff..d0cdf750 100644
--- a/frontend/public/locales/ja/common.json
+++ b/frontend/public/locales/ja/common.json
@@ -218,6 +218,7 @@
"transactions": {
"title": "最近の支払い",
"noTransactions": "取引履歴はまだありません",
+ "newest": "最新",
"startMessage": "最初の支払いを送信して開始",
"needTestXlm": "テスト XLM が必要ですか?",
"fundWithFriendbot": "Friendbot でこのアカウントに資金追加",
diff --git a/frontend/public/locales/pt/common.json b/frontend/public/locales/pt/common.json
index 299713c4..52d2e2b7 100644
--- a/frontend/public/locales/pt/common.json
+++ b/frontend/public/locales/pt/common.json
@@ -218,6 +218,7 @@
"transactions": {
"title": "Pagamentos Recentes",
"noTransactions": "Nenhuma transação ainda",
+ "newest": "Mais recente",
"startMessage": "Envie seu primeiro pagamento para começar",
"needTestXlm": "Precisa de XLM de teste?",
"fundWithFriendbot": "Financie esta conta com Friendbot",