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' ;
164import { 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' ;
277import { TableSkeleton } from '@/components/skeletons/TableSkeleton' ;
288import { usePayments , type ApiPayment } from '@/lib/api/hooks' ;
299import { formatDate } from '@/lib/utils/format' ;
@@ -34,7 +14,6 @@ import { getStellarExplorerTxUrl } from '@/lib/utils/explorer';
3414import { useVirtualizer } from '@tanstack/react-virtual' ;
3515import { TransactionDrawer } from '@/components/transactions/TransactionDrawer' ;
3616import { useOfflineStore } from '@/lib/store/offlineStore' ;
37- import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from '@/components/ui' ;
3817import { useNotify } from '@/lib/hooks/useNotify' ;
3918
4019type 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-
9041const 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+
150150export 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 >
0 commit comments