Skip to content

Commit b81f90b

Browse files
Abidoyesimzeclaude
andcommitted
feat(frontend): extract accessible pagination component for Transaction History
Payment history previously fetched a hardcoded first page (LIMIT=50) and showed a static "End of list" message even though the backend already supports full page/limit/total_pages pagination. Extracts a pure, prop-driven TransactionHistoryPagination component (keyboard accessible, windowed page numbers, mobile-compact "Page X of Y" view) and wires real page navigation through the existing URL-driven filter state. A literal full React Server Component migration isn't compatible with this page's live WebSocket updates and instant client-side filtering, so the new component is deliberately data-fetching-free and controlled via props/ callback, making it trivially reusable if the page is ever split into a server shell + client island. Closes #1220 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 1ebfe9e commit b81f90b

6 files changed

Lines changed: 296 additions & 32 deletions

File tree

frontend/messages/en.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,15 @@
214214
"confirmed": "Confirmed",
215215
"failed": "Failed",
216216
"refunded": "Refunded"
217+
},
218+
"pagination": {
219+
"ariaLabel": "Payment history pagination",
220+
"previous": "Previous page",
221+
"next": "Next page",
222+
"goToPage": "Go to page {page}",
223+
"currentPage": "Current page, page {page}",
224+
"pageLabel": "Page {page} of {totalPages}",
225+
"range": "Showing {start}-{end} of {total}"
217226
}
218227
},
219228
"walletSelector": {
@@ -453,4 +462,4 @@
453462
"notificationLabel": "Notification: {message}",
454463
"timestampLabel": "Timestamp: {timestamp}"
455464
}
456-
}
465+
}

frontend/messages/es.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,15 @@
214214
"confirmed": "Confirmado",
215215
"failed": "Fallido",
216216
"refunded": "Reembolsado"
217+
},
218+
"pagination": {
219+
"ariaLabel": "Paginación del historial de pagos",
220+
"previous": "Página anterior",
221+
"next": "Página siguiente",
222+
"goToPage": "Ir a la página {page}",
223+
"currentPage": "Página actual, página {page}",
224+
"pageLabel": "Página {page} de {totalPages}",
225+
"range": "Mostrando {start}-{end} de {total}"
217226
}
218227
},
219228
"walletSelector": {

frontend/messages/pt.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,15 @@
214214
"confirmed": "Confirmado",
215215
"failed": "Falhou",
216216
"refunded": "Reembolsado"
217+
},
218+
"pagination": {
219+
"ariaLabel": "Paginação do histórico de pagamentos",
220+
"previous": "Página anterior",
221+
"next": "Próxima página",
222+
"goToPage": "Ir para a página {page}",
223+
"currentPage": "Página atual, página {page}",
224+
"pageLabel": "Página {page} de {totalPages}",
225+
"range": "Mostrando {start}-{end} de {total}"
217226
}
218227
},
219228
"walletSelector": {

frontend/src/app/(authenticated)/payment-history/page.tsx

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
66
import { useLocale, useTranslations } from "next-intl";
77
import Skeleton from "react-loading-skeleton";
88
import "react-loading-skeleton/dist/skeleton.css";
9+
import TransactionHistoryPagination from "@/components/TransactionHistoryPagination";
910
import { localeToLanguageTag } from "@/i18n/config";
1011
import { toast } from "sonner";
1112
import {
@@ -43,6 +44,7 @@ interface Payment {
4344
interface PaginatedResponse {
4445
payments: Payment[];
4546
total_count: number;
47+
total_pages: number;
4648
}
4749

4850
const LIMIT = 50;
@@ -115,6 +117,7 @@ export default function PaymentHistoryPage() {
115117
const [loading, setLoading] = useState(true);
116118
const [error, setError] = useState<string | null>(null);
117119
const [totalCount, setTotalCount] = useState(0);
120+
const [totalPages, setTotalPages] = useState(0);
118121
const [selectedPayment, setSelectedPayment] = useState<string | null>(null);
119122
const [hoveredPayment, setHoveredPayment] = useState<string | null>(null);
120123
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -200,6 +203,7 @@ export default function PaymentHistoryPage() {
200203
const data: PaginatedResponse = await response.json();
201204
setPayments(data.payments ?? []);
202205
setTotalCount(data.total_count ?? 0);
206+
setTotalPages(data.total_pages ?? 0);
203207
} catch (err: unknown) {
204208
if (err instanceof Error && err.name === "AbortError") return;
205209
setError(err instanceof Error ? err.message : t("loadFailed"));
@@ -217,9 +221,6 @@ export default function PaymentHistoryPage() {
217221
setSelectedPayment(paymentId);
218222
setIsSheetOpen(true);
219223
};
220-
const totalPages = Math.max(1, Math.ceil(totalCount / LIMIT));
221-
const pageStart = totalCount === 0 ? 0 : (currentPage - 1) * LIMIT + 1;
222-
const pageEnd = Math.min(currentPage * LIMIT, totalCount);
223224

224225
// ── Loading state ─────────────────────────────────────────────────────────────
225226
if (loading) {
@@ -461,9 +462,7 @@ export default function PaymentHistoryPage() {
461462
{/* Results count */}
462463
<div className="flex items-center justify-between px-2">
463464
<p className="text-xs text-[#6B6B6B] font-medium">
464-
{totalPages > 1
465-
? `Showing ${pageStart}-${pageEnd} of ${totalCount}`
466-
: t("showingResults", { shown: payments.length, total: totalCount })}
465+
{t("showingResults", { shown: payments.length, total: totalCount })}
467466
</p>
468467
</div>
469468

@@ -534,31 +533,14 @@ export default function PaymentHistoryPage() {
534533
</div>
535534
)}
536535

537-
{totalPages > 1 && (
538-
<div className="flex flex-col items-center justify-between gap-3 border-t border-[#F0F0F0] py-6 sm:flex-row">
539-
<p className="text-[10px] font-bold uppercase tracking-widest text-[#A0A0A0]">
540-
Page {currentPage} of {totalPages}
541-
</p>
542-
<nav className="flex items-center gap-2" aria-label="Transaction history pagination">
543-
<button
544-
type="button"
545-
onClick={() => handlePageChange(currentPage - 1)}
546-
disabled={currentPage <= 1 || isFilterPending}
547-
className="inline-flex min-h-10 items-center rounded-xl border border-[#E8E8E8] bg-white px-4 text-[10px] font-bold uppercase tracking-widest text-[#0A0A0A] transition-all hover:bg-[#F5F5F5] disabled:cursor-not-allowed disabled:opacity-40"
548-
>
549-
Previous
550-
</button>
551-
<button
552-
type="button"
553-
onClick={() => handlePageChange(currentPage + 1)}
554-
disabled={currentPage >= totalPages || isFilterPending}
555-
className="inline-flex min-h-10 items-center rounded-xl bg-[#0A0A0A] px-4 text-[10px] font-bold uppercase tracking-widest text-white transition-all hover:bg-[#2A2A2A] disabled:cursor-not-allowed disabled:opacity-40"
556-
>
557-
Next
558-
</button>
559-
</nav>
560-
</div>
561-
)}
536+
<TransactionHistoryPagination
537+
page={currentPage}
538+
totalPages={totalPages}
539+
totalCount={totalCount}
540+
limit={LIMIT}
541+
onPageChange={handlePageChange}
542+
disabled={loading || isFilterPending}
543+
/>
562544
</div>
563545
</div>
564546

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import "@testing-library/jest-dom/vitest";
4+
import { NextIntlClientProvider } from "next-intl";
5+
import TransactionHistoryPagination from "./TransactionHistoryPagination";
6+
7+
const messages = {
8+
recentPayments: {
9+
pagination: {
10+
ariaLabel: "Payment history pagination",
11+
previous: "Previous page",
12+
next: "Next page",
13+
goToPage: "Go to page {page}",
14+
currentPage: "Current page, page {page}",
15+
pageLabel: "Page {page} of {totalPages}",
16+
range: "Showing {start}-{end} of {total}",
17+
},
18+
},
19+
};
20+
21+
function renderPagination(props: Partial<React.ComponentProps<typeof TransactionHistoryPagination>> = {}) {
22+
const defaultProps = {
23+
page: 1,
24+
totalPages: 5,
25+
totalCount: 220,
26+
limit: 50,
27+
onPageChange: vi.fn(),
28+
};
29+
const merged = { ...defaultProps, ...props };
30+
31+
render(
32+
<NextIntlClientProvider locale="en" messages={messages}>
33+
<TransactionHistoryPagination {...merged} />
34+
</NextIntlClientProvider>,
35+
);
36+
37+
return merged;
38+
}
39+
40+
describe("TransactionHistoryPagination", () => {
41+
it("renders nothing when there is only one page", () => {
42+
const { container } = render(
43+
<NextIntlClientProvider locale="en" messages={messages}>
44+
<TransactionHistoryPagination page={1} totalPages={1} totalCount={10} limit={50} onPageChange={vi.fn()} />
45+
</NextIntlClientProvider>,
46+
);
47+
expect(container).toBeEmptyDOMElement();
48+
});
49+
50+
it("renders nothing when there are zero pages", () => {
51+
const { container } = render(
52+
<NextIntlClientProvider locale="en" messages={messages}>
53+
<TransactionHistoryPagination page={1} totalPages={0} totalCount={0} limit={50} onPageChange={vi.fn()} />
54+
</NextIntlClientProvider>,
55+
);
56+
expect(container).toBeEmptyDOMElement();
57+
});
58+
59+
it("shows the correct result range", () => {
60+
renderPagination({ page: 2, totalPages: 5, totalCount: 220, limit: 50 });
61+
expect(screen.getByText("Showing 51-100 of 220")).toBeInTheDocument();
62+
});
63+
64+
it("disables the previous button on the first page", () => {
65+
renderPagination({ page: 1 });
66+
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
67+
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
68+
});
69+
70+
it("disables the next button on the last page", () => {
71+
renderPagination({ page: 5, totalPages: 5 });
72+
expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
73+
expect(screen.getByRole("button", { name: "Previous page" })).toBeEnabled();
74+
});
75+
76+
it("calls onPageChange with the next page when Next is clicked", () => {
77+
const { onPageChange } = renderPagination({ page: 2, totalPages: 5 });
78+
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
79+
expect(onPageChange).toHaveBeenCalledWith(3);
80+
});
81+
82+
it("calls onPageChange with the previous page when Previous is clicked", () => {
83+
const { onPageChange } = renderPagination({ page: 2, totalPages: 5 });
84+
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
85+
expect(onPageChange).toHaveBeenCalledWith(1);
86+
});
87+
88+
it("calls onPageChange with a specific page number when clicked", () => {
89+
const { onPageChange } = renderPagination({ page: 1, totalPages: 5 });
90+
fireEvent.click(screen.getByRole("button", { name: "Go to page 3" }));
91+
expect(onPageChange).toHaveBeenCalledWith(3);
92+
});
93+
94+
it("marks the current page with aria-current", () => {
95+
renderPagination({ page: 3, totalPages: 5 });
96+
const current = screen.getByRole("button", { name: "Current page, page 3" });
97+
expect(current).toHaveAttribute("aria-current", "page");
98+
});
99+
100+
it("collapses long page ranges with an ellipsis", () => {
101+
renderPagination({ page: 5, totalPages: 12 });
102+
expect(screen.getAllByText("…").length).toBeGreaterThan(0);
103+
expect(screen.getByRole("button", { name: "Go to page 1" })).toBeInTheDocument();
104+
expect(screen.getByRole("button", { name: "Go to page 12" })).toBeInTheDocument();
105+
});
106+
107+
it("disables all controls when disabled prop is set", () => {
108+
renderPagination({ page: 2, totalPages: 5, disabled: true });
109+
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
110+
expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
111+
expect(screen.getByRole("button", { name: "Go to page 1" })).toBeDisabled();
112+
});
113+
});

0 commit comments

Comments
 (0)