Skip to content

Commit f6d6b0e

Browse files
authored
Merge pull request #401 from bade2brazy/fix/issues-294-295-296-297
i18n key autocompletion, provider test coverage, transactions perf + clear-filters
2 parents 497da72 + 9fcee0e commit f6d6b0e

5 files changed

Lines changed: 239 additions & 142 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
3+
import React from 'react';
4+
import { render, screen, fireEvent } from '@testing-library/react';
5+
import TransactionsPage from '@/app/(merchant)/transactions/page';
6+
7+
const mockPayments = [
8+
{ id: 'pay_1', txHash: 'hash1', payerAddress: 'GAAA1111', merchantId: 'm_1', amountUsdc: 750, amountNgn: 1162500, fxRate: 1550, status: 'completed', source: 'Consulting', createdAt: new Date().toISOString() },
9+
{ id: 'pay_2', txHash: 'hash2', payerAddress: 'GBBB2222', merchantId: 'm_1', amountUsdc: 45.5, amountNgn: 70525, fxRate: 1550, status: 'pending', source: 'E-commerce', createdAt: new Date().toISOString() },
10+
];
11+
12+
jest.mock('@/lib/api/hooks', () => ({
13+
usePayments: () => ({ data: mockPayments, isLoading: false, error: null, refetch: jest.fn() }),
14+
}));
15+
16+
jest.mock('@/lib/store/offlineStore', () => ({
17+
useOfflineStore: (selector: any) => selector({ isOnline: true }),
18+
}));
19+
20+
jest.mock('@tanstack/react-virtual', () => ({
21+
useVirtualizer: ({ count }: { count: number }) => ({
22+
getVirtualItems: () =>
23+
Array.from({ length: count }, (_, index) => ({ key: index, index, start: index * 48 })),
24+
getTotalSize: () => count * 48,
25+
}),
26+
}));
27+
28+
jest.mock('@/components/transactions/TransactionDrawer', () => ({
29+
TransactionDrawer: () => null,
30+
}));
31+
32+
// Swap the Base UI Select primitives for plain native <select> elements so
33+
// filter interactions can be driven with a single fireEvent.change instead
34+
// of simulating Base UI's portal-based popup/pointer-event flow. Everything
35+
// else exported from '@/components/ui' stays real.
36+
jest.mock('@/components/ui', () => {
37+
const actual = jest.requireActual('@/components/ui');
38+
return {
39+
...actual,
40+
Select: ({ value, onValueChange, children }: any) => (
41+
<select
42+
aria-label="select"
43+
value={value}
44+
onChange={(e) => onValueChange(e.target.value)}
45+
>
46+
{children}
47+
</select>
48+
),
49+
SelectTrigger: () => null,
50+
SelectValue: () => null,
51+
SelectContent: ({ children }: any) => <>{children}</>,
52+
SelectItem: ({ value, children }: any) => <option value={value}>{children}</option>,
53+
};
54+
});
55+
56+
describe('TransactionsPage filters', () => {
57+
it('does not show the clear-all-filters button when no filters are active', () => {
58+
render(<TransactionsPage />);
59+
expect(screen.queryByText('Clear all filters')).not.toBeInTheDocument();
60+
});
61+
62+
it('shows the clear-all-filters button once a filter is active, and resets all filters on click', () => {
63+
render(<TransactionsPage />);
64+
65+
const selects = screen.getAllByLabelText('select');
66+
const [statusSelect] = selects;
67+
68+
fireEvent.change(statusSelect, { target: { value: 'completed' } });
69+
70+
const clearButton = screen.getByText('Clear all filters');
71+
expect(clearButton).toBeInTheDocument();
72+
73+
fireEvent.click(clearButton);
74+
75+
expect(screen.queryByText('Clear all filters')).not.toBeInTheDocument();
76+
expect((screen.getAllByLabelText('select')[0] as HTMLSelectElement).value).toBe('all');
77+
});
78+
79+
it('renders seeded transactions by default', () => {
80+
render(<TransactionsPage />);
81+
expect(screen.getAllByText(/GAAA1111|GBBB2222/).length).toBeGreaterThan(0);
82+
});
83+
});

app/(merchant)/transactions/page.tsx

Lines changed: 86 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,9 @@
11
"use client";
22

3-
import { useState } from 'react';
4-
import { Card, CardContent, CardHeader } from '@/components/ui/card';
5-
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
6-
import { Input } from '@/components/ui/input';
7-
import { Button } from '@/components/ui/button';
8-
import { StatusBadge } from '@/components/shared/StatusBadge';
9-
import { CopyAddress } from '@/components/shared/CopyAddress';
10-
import { CurrencyDisplay } from '@/components/shared/CurrencyDisplay';
11-
import { mockTransactions, Transaction } from '@/lib/mock/transactions';
12-
import { formatDate } from '@/lib/utils/format';
13-
import { Search, Download, Filter } from 'lucide-react';
14-
import { generateCSV, CSVColumn } from '@/lib/utils/csv';
15-
import { useState, memo, useMemo, useRef } from 'react';
3+
import { memo, useCallback, useMemo, useRef, useState } from 'react';
164
import { useDebounceValue } from 'usehooks-ts';
17-
import { Card, CardContent } from '@/components/ui';
18-
import { Input } from '@/components/ui';
19-
import { Button } from '@/components/ui';
20-
import { Skeleton } from '@/components/ui';
21-
import { NetworkTooltip } from '@/components/ui';
22-
import { StatusBadge } from '@/components/shared';
23-
import { CopyAddress } from '@/components/shared';
24-
import { CurrencyDisplay } from '@/components/shared';
25-
import { ErrorDisplay } from '@/components/shared';
26-
import { EmptyState } from '@/components/shared';
5+
import { Card, CardContent, Input, Button, Skeleton, NetworkTooltip, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui';
6+
import { StatusBadge, CopyAddress, CurrencyDisplay, ErrorDisplay, EmptyState } from '@/components/shared';
277
import { TableSkeleton } from '@/components/skeletons/TableSkeleton';
288
import { usePayments, type ApiPayment } from '@/lib/api/hooks';
299
import { formatDate } from '@/lib/utils/format';
@@ -34,7 +14,6 @@ import { getStellarExplorerTxUrl } from '@/lib/utils/explorer';
3414
import { useVirtualizer } from '@tanstack/react-virtual';
3515
import { TransactionDrawer } from '@/components/transactions/TransactionDrawer';
3616
import { useOfflineStore } from '@/lib/store/offlineStore';
37-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui';
3817
import { useNotify } from '@/lib/hooks/useNotify';
3918

4019
type Transaction = ApiPayment;
@@ -59,34 +38,6 @@ interface TransactionCardProps {
5938
onClick: (tx: Transaction) => void;
6039
}
6140

62-
const handleExportCSV = () => {
63-
const columns: CSVColumn<Transaction>[] = [
64-
{ header: 'Date', key: (tx) => formatDate(tx.timestamp) },
65-
{ header: 'Payer', key: 'payerAddress' },
66-
{ header: 'Tx Hash', key: 'txHash' },
67-
{ header: 'Source', key: 'source' },
68-
{ header: 'Amount (USDC)', key: 'amountUsdc' },
69-
{ header: 'Amount (NGN)', key: 'amountNgn' },
70-
{ header: 'FX Rate', key: 'fxRate' },
71-
{ header: 'Status', key: 'status' }
72-
];
73-
74-
const csvContent = generateCSV(filteredTransactions, columns);
75-
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
76-
const url = URL.createObjectURL(blob);
77-
const link = document.createElement('a');
78-
79-
// Format date as YYYY-MM-DD
80-
const dateStr = new Date().toISOString().split('T')[0];
81-
82-
link.setAttribute('href', url);
83-
link.setAttribute('download', `bettapay-transactions-${dateStr}.csv`);
84-
link.style.visibility = 'hidden';
85-
document.body.appendChild(link);
86-
link.click();
87-
document.body.removeChild(link);
88-
};
89-
9041
const TransactionCard = memo(function TransactionCard({ tx, onClick }: TransactionCardProps) {
9142
return (
9243
<div
@@ -102,15 +53,6 @@ const TransactionCard = memo(function TransactionCard({ tx, onClick }: Transacti
10253
<span className="text-xs text-muted-foreground">Payer</span>
10354
<CopyAddress address={tx.payerAddress ?? ''} />
10455
</div>
105-
<div className="flex gap-2">
106-
<Button variant="outline" className="border-border/50 bg-brand-surface">
107-
<Filter className="w-4 h-4 mr-2" />
108-
Filter
109-
</Button>
110-
<Button onClick={handleExportCSV} variant="outline" className="border-border/50 bg-brand-surface">
111-
<Download className="w-4 h-4 mr-2" />
112-
Export CSV
113-
</Button>
11456
<div className="flex items-center justify-between">
11557
<span className="text-xs text-muted-foreground">Tx Hash</span>
11658
<div className="flex items-center gap-2">
@@ -147,12 +89,73 @@ const TransactionCard = memo(function TransactionCard({ tx, onClick }: Transacti
14789
);
14890
});
14991

92+
interface TransactionRowProps {
93+
tx: Transaction;
94+
translateY: number;
95+
onClick: (tx: Transaction) => void;
96+
}
97+
98+
// Rendered inline inside virtualItems.map(...) previously — every row got a
99+
// brand-new inline JSX subtree on each parent render (e.g. every keystroke
100+
// in the search input), so React re-rendered the entire visible table body
101+
// even though debouncedSearch hadn't changed yet. Extracting a memoized row
102+
// component gives React a props-based bailout, mirroring TransactionCard.
103+
const TransactionRow = memo(function TransactionRow({ tx, translateY, onClick }: TransactionRowProps) {
104+
return (
105+
<tr
106+
className="border-border/50 hover:bg-muted/30 cursor-pointer border-b"
107+
onClick={() => onClick(tx)}
108+
style={{ transform: `translateY(${translateY}px)` }}
109+
>
110+
<td className="text-muted-foreground whitespace-nowrap px-4 py-2 text-sm">
111+
{formatDate(tx.createdAt)}
112+
</td>
113+
<td className="px-4 py-2 text-sm">
114+
<CopyAddress address={tx.payerAddress ?? ''} />
115+
</td>
116+
<td className="px-4 py-2 text-sm">
117+
<CopyAddress address={tx.txHash ?? ''} />
118+
</td>
119+
<td className="text-muted-foreground px-4 py-2 text-sm">
120+
{tx.source ?? '—'}
121+
</td>
122+
<td className="text-right font-medium px-4 py-2 text-sm">
123+
<CurrencyDisplay amount={tx.amountUsdc} currency="USDC" />
124+
</td>
125+
<td className="text-right text-muted-foreground px-4 py-2 text-sm">
126+
<CurrencyDisplay amount={tx.amountNgn ?? 0} currency="NGN" showDecimals={false} />
127+
</td>
128+
<td className="text-center px-4 py-2 text-sm">
129+
<StatusBadge status={tx.status} />
130+
</td>
131+
<td className="w-[80px] text-center px-4 py-2 text-sm">
132+
{tx.txHash && (
133+
<a
134+
href={getStellarExplorerTxUrl(tx.txHash)}
135+
target="_blank"
136+
rel="noopener noreferrer"
137+
aria-label="View on Stellar Explorer"
138+
onClick={(e) => e.stopPropagation()}
139+
>
140+
<Button variant="ghost" size="icon" className="min-h-[44px] min-w-[44px] rounded-lg">
141+
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground" />
142+
</Button>
143+
</a>
144+
)}
145+
</td>
146+
</tr>
147+
);
148+
});
149+
150150
export default function TransactionsPage() {
151151
const { data: payments, isLoading, error: fetchError, refetch } = usePayments();
152152
const notify = useNotify();
153153

154154
const [searchTerm, setSearchTerm] = useState('');
155-
const sanitizedOnChange = (value: string) => setSearchTerm(sanitizeSearchQuery(value));
155+
const sanitizedOnChange = useCallback(
156+
(value: string) => setSearchTerm(sanitizeSearchQuery(value)),
157+
[],
158+
);
156159
const [debouncedSearch] = useDebounceValue(searchTerm, 300);
157160

158161
const [statusFilter, setStatusFilter] = useState('all');
@@ -195,6 +198,12 @@ export default function TransactionsPage() {
195198

196199
const activeFilterCount = (statusFilter !== 'all' ? 1 : 0) + (assetFilter !== 'all' ? 1 : 0) + (dateRangeFilter !== 'all' ? 1 : 0);
197200

201+
const handleClearFilters = () => {
202+
setStatusFilter('all');
203+
setAssetFilter('all');
204+
setDateRangeFilter('all');
205+
};
206+
198207
const handleExportCsv = () => {
199208
if (filteredTransactions.length === 0) {
200209
notify.error("No transactions to export");
@@ -319,6 +328,16 @@ export default function TransactionsPage() {
319328
</SelectContent>
320329
</Select>
321330

331+
{activeFilterCount > 0 && (
332+
<Button
333+
variant="ghost"
334+
className="text-muted-foreground hover:text-foreground"
335+
onClick={handleClearFilters}
336+
>
337+
Clear all filters
338+
</Button>
339+
)}
340+
322341
<NetworkTooltip show={!isOnline}>
323342
<Button
324343
variant="outline"
@@ -403,51 +422,12 @@ export default function TransactionsPage() {
403422
{virtualItems.map((virtualItem) => {
404423
const tx = filteredTransactions[virtualItem.index];
405424
return (
406-
<tr
425+
<TransactionRow
407426
key={virtualItem.key}
408-
className="border-border/50 hover:bg-muted/30 cursor-pointer border-b"
409-
onClick={() => setSelectedTx(tx)}
410-
style={{
411-
transform: `translateY(${virtualItem.start}px)`,
412-
}}
413-
>
414-
<td className="text-muted-foreground whitespace-nowrap px-4 py-2 text-sm">
415-
{formatDate(tx.createdAt)}
416-
</td>
417-
<td className="px-4 py-2 text-sm">
418-
<CopyAddress address={tx.payerAddress ?? ''} />
419-
</td>
420-
<td className="px-4 py-2 text-sm">
421-
<CopyAddress address={tx.txHash ?? ''} />
422-
</td>
423-
<td className="text-muted-foreground px-4 py-2 text-sm">
424-
{tx.source ?? '—'}
425-
</td>
426-
<td className="text-right font-medium px-4 py-2 text-sm">
427-
<CurrencyDisplay amount={tx.amountUsdc} currency="USDC" />
428-
</td>
429-
<td className="text-right text-muted-foreground px-4 py-2 text-sm">
430-
<CurrencyDisplay amount={tx.amountNgn ?? 0} currency="NGN" showDecimals={false} />
431-
</td>
432-
<td className="text-center px-4 py-2 text-sm">
433-
<StatusBadge status={tx.status} />
434-
</td>
435-
<td className="w-[80px] text-center px-4 py-2 text-sm">
436-
{tx.txHash && (
437-
<a
438-
href={getStellarExplorerTxUrl(tx.txHash)}
439-
target="_blank"
440-
rel="noopener noreferrer"
441-
aria-label="View on Stellar Explorer"
442-
onClick={(e) => e.stopPropagation()}
443-
>
444-
<Button variant="ghost" size="icon" className="min-h-[44px] min-w-[44px] rounded-lg">
445-
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground" />
446-
</Button>
447-
</a>
448-
)}
449-
</td>
450-
</tr>
427+
tx={tx}
428+
translateY={virtualItem.start}
429+
onClick={setSelectedTx}
430+
/>
451431
);
452432
})}
453433
</tbody>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import { useTranslation } from "react-i18next";
3+
4+
import { I18nProvider } from "../I18nProvider";
5+
import { localeStorageKey } from "@/lib/i18n/config";
6+
7+
function Probe() {
8+
const { i18n } = useTranslation();
9+
return <span data-testid="resolved-locale">{i18n.resolvedLanguage}</span>;
10+
}
11+
12+
function mockNavigatorLanguages(languages: string[]) {
13+
Object.defineProperty(window.navigator, "languages", { value: languages, configurable: true });
14+
Object.defineProperty(window.navigator, "language", { value: languages[0], configurable: true });
15+
}
16+
17+
describe("I18nProvider — browser language detection", () => {
18+
beforeEach(() => {
19+
window.localStorage.clear();
20+
});
21+
22+
it("detects a supported browser language and switches to it after mount", async () => {
23+
mockNavigatorLanguages(["fr-FR", "en-US"]);
24+
25+
render(
26+
<I18nProvider>
27+
<Probe />
28+
</I18nProvider>,
29+
);
30+
31+
await waitFor(() => expect(screen.getByTestId("resolved-locale")).toHaveTextContent("fr"));
32+
expect(document.documentElement.lang).toBe("fr");
33+
});
34+
35+
it("falls back to English when the browser language is unsupported", async () => {
36+
mockNavigatorLanguages(["de-DE"]);
37+
38+
render(
39+
<I18nProvider>
40+
<Probe />
41+
</I18nProvider>,
42+
);
43+
44+
await waitFor(() => expect(screen.getByTestId("resolved-locale")).toHaveTextContent("en"));
45+
});
46+
47+
it("prefers a previously persisted locale over the browser language", async () => {
48+
window.localStorage.setItem(localeStorageKey, "pt");
49+
mockNavigatorLanguages(["fr-FR"]);
50+
51+
render(
52+
<I18nProvider>
53+
<Probe />
54+
</I18nProvider>,
55+
);
56+
57+
await waitFor(() => expect(screen.getByTestId("resolved-locale")).toHaveTextContent("pt"));
58+
});
59+
});

0 commit comments

Comments
 (0)