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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions hooks/__tests__/use-transaction-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { act, renderHook } from '@testing-library/react'
import { useTransactionHistory } from '../use-transaction-history'
import { mockTransactions } from '@/lib/fixtures/transactions'

describe('useTransactionHistory', () => {
it('filters and sorts transactions before pagination', () => {
const { result } = renderHook(() => useTransactionHistory({ transactions: mockTransactions, pageSize: 3 }))

act(() => {
result.current.onFilterChange('failed')
})

expect(result.current.filteredTransactions).toHaveLength(2)
expect(result.current.filteredTransactions.map((transaction) => transaction.id)).toEqual([
'BIL-240162',
'ONR-240132',
])

act(() => {
result.current.onSortChange('amount')
})

expect(result.current.sortedTransactions[0].id).toBe('ONR-240132')
expect(result.current.sortedTransactions[0].amount).toBe(10000)

expect(result.current.totalPages).toBe(1)
})

it('moves to the next page when requested', () => {
const { result } = renderHook(() => useTransactionHistory({ transactions: mockTransactions, pageSize: 3 }))

act(() => {
result.current.onPageChange(2)
})

expect(result.current.currentPage).toBe(2)
expect(result.current.paginatedTransactions.map((transaction) => transaction.id)).toEqual([
'ONR-240173',
'OFF-240166',
'BIL-240162',
])
})

it('uses the server total count for pagination when serverMode is enabled', () => {
const pageChange = jest.fn()
const serverTransactions = mockTransactions.slice(0, 2)
const { result } = renderHook(() =>
useTransactionHistory({
transactions: serverTransactions,
pageSize: 2,
serverMode: true,
serverTotalCount: 7,
onPageChange: pageChange,
})
)

expect(result.current.totalPages).toBe(4)
expect(result.current.currentPage).toBe(1)
expect(result.current.paginatedTransactions).toEqual(serverTransactions)

act(() => {
result.current.onPageChange(3)
})

expect(result.current.currentPage).toBe(3)
expect(pageChange).toHaveBeenCalledWith(3)
})
})
143 changes: 143 additions & 0 deletions hooks/use-transaction-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useMemo, useState } from 'react'
import type { QuickFilter, SortDirection, SortField, Transaction } from '@/lib/fixtures/transactions'
import { type TransactionStatus } from '@/lib/fixtures/transactions'

interface UseTransactionHistoryOptions {
transactions: Transaction[]
pageSize?: number
initialSortField?: SortField
initialSortDirection?: SortDirection
serverMode?: boolean
serverTotalCount?: number
onPageChange?: (nextPage: number) => void
}

interface UseTransactionHistoryResult {
quickFilter: QuickFilter
sortField: SortField
sortDirection: SortDirection
page: number
filteredTransactions: Transaction[]
sortedTransactions: Transaction[]
paginatedTransactions: Transaction[]
totalPages: number
currentPage: number
onFilterChange: (filter: QuickFilter) => void
onSortChange: (field: SortField) => void
onPageChange: (nextPage: number) => void
}

const statusOrder: Record<TransactionStatus, number> = {
completed: 3,
pending: 2,
failed: 1,
}

export function useTransactionHistory({
transactions,
pageSize = 5,
initialSortField = 'date',
initialSortDirection = 'desc',
serverMode = false,
serverTotalCount,
onPageChange: onPageChangeCallback,
}: UseTransactionHistoryOptions): UseTransactionHistoryResult {
const [quickFilter, setQuickFilter] = useState<QuickFilter>('all')
const [sortField, setSortField] = useState<SortField>(initialSortField)
const [sortDirection, setSortDirection] = useState<SortDirection>(initialSortDirection)
const [page, setPage] = useState(1)

const filteredTransactions = useMemo(() => {
if (serverMode) return transactions
if (quickFilter === 'all') return transactions
if (quickFilter === 'failed') return transactions.filter((tx) => tx.status === 'failed')
return transactions.filter((tx) => tx.type === quickFilter)
}, [quickFilter, serverMode, transactions])

const sortedTransactions = useMemo(() => {
if (serverMode) return transactions

return [...filteredTransactions].sort((a, b) => {
let aValue: string | number = 0
let bValue: string | number = 0

switch (sortField) {
case 'date':
aValue = new Date(a.date).getTime()
bValue = new Date(b.date).getTime()
break
case 'type':
aValue = a.type
bValue = b.type
break
case 'asset':
aValue = a.asset
bValue = b.asset
break
case 'amount':
aValue = a.amount
bValue = b.amount
break
case 'status':
aValue = statusOrder[a.status]
bValue = statusOrder[b.status]
break
}

const result =
typeof aValue === 'string' && typeof bValue === 'string'
? aValue.localeCompare(bValue)
: Number(aValue) - Number(bValue)

return sortDirection === 'asc' ? result : -result
})
}, [filteredTransactions, serverMode, sortDirection, sortField, transactions])

const totalPages = Math.max(
1,
Math.ceil((serverMode ? serverTotalCount ?? transactions.length : sortedTransactions.length) / pageSize)
)
const currentPage = Math.min(page, totalPages)

const paginatedTransactions = useMemo(() => {
if (serverMode) return transactions

const start = (currentPage - 1) * pageSize
return sortedTransactions.slice(start, start + pageSize)
}, [currentPage, pageSize, serverMode, sortedTransactions, transactions])

const onFilterChange = (filter: QuickFilter) => {
setQuickFilter(filter)
setPage(1)
}

const onSortChange = (field: SortField) => {
setPage(1)
if (sortField === field) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
return
}
setSortField(field)
setSortDirection('desc')
}

const onPageChange = (nextPage: number) => {
setPage(nextPage)
onPageChangeCallback?.(nextPage)
}

return {
quickFilter,
sortField,
sortDirection,
page,
filteredTransactions,
sortedTransactions,
paginatedTransactions,
totalPages,
currentPage,
onFilterChange,
onSortChange,
onPageChange,
}
}
126 changes: 126 additions & 0 deletions lib/fixtures/transactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
export type TransactionType = 'onramp' | 'offramp' | 'billpay'
export type TransactionStatus = 'pending' | 'completed' | 'failed'

export interface Transaction {
id: string
date: string
type: TransactionType
amount: number
asset: string
counterparty: string
status: TransactionStatus
}

export type SortField = 'date' | 'type' | 'asset' | 'amount' | 'status'
export type SortDirection = 'asc' | 'desc'
export type QuickFilter = 'all' | 'onramp' | 'offramp' | 'billpay' | 'failed'

export const mockTransactions: Transaction[] = [
{
id: 'ONR-240191',
date: '2026-02-26T08:22:00.000Z',
type: 'onramp',
amount: 15000,
asset: 'cNGN',
counterparty: 'From Zenith Bank',
status: 'completed',
},
{
id: 'OFF-240180',
date: '2026-02-26T07:40:00.000Z',
type: 'offramp',
amount: 8700,
asset: 'USDC',
counterparty: 'To MTN Mobile Money',
status: 'pending',
},
{
id: 'BIL-240178',
date: '2026-02-25T16:11:00.000Z',
type: 'billpay',
amount: 5500,
asset: 'cNGN',
counterparty: 'To IKEDC Electricity',
status: 'completed',
},
{
id: 'ONR-240173',
date: '2026-02-25T11:35:00.000Z',
type: 'onramp',
amount: 25000,
asset: 'cNGN',
counterparty: 'From Access Bank',
status: 'pending',
},
{
id: 'OFF-240166',
date: '2026-02-24T19:02:00.000Z',
type: 'offramp',
amount: 12000,
asset: 'USDT',
counterparty: 'To Kuda Bank',
status: 'completed',
},
{
id: 'BIL-240162',
date: '2026-02-24T09:43:00.000Z',
type: 'billpay',
amount: 2100,
asset: 'cNGN',
counterparty: 'To Glo Airtime',
status: 'failed',
},
{
id: 'ONR-240158',
date: '2026-02-23T20:10:00.000Z',
type: 'onramp',
amount: 8000,
asset: 'cNGN',
counterparty: 'From GTBank',
status: 'completed',
},
{
id: 'BIL-240151',
date: '2026-02-23T08:37:00.000Z',
type: 'billpay',
amount: 4300,
asset: 'cNGN',
counterparty: 'To DSTV',
status: 'completed',
},
{
id: 'OFF-240144',
date: '2026-02-22T22:29:00.000Z',
type: 'offramp',
amount: 16000,
asset: 'USDC',
counterparty: 'To Opay Wallet',
status: 'completed',
},
{
id: 'ONR-240132',
date: '2026-02-22T10:04:00.000Z',
type: 'onramp',
amount: 10000,
asset: 'cNGN',
counterparty: 'From Moniepoint',
status: 'failed',
},
{
id: 'OFF-240120',
date: '2026-02-21T14:18:00.000Z',
type: 'offramp',
amount: 7300,
asset: 'USDT',
counterparty: 'To First Bank',
status: 'completed',
},
]

export const quickFilters: Array<{ key: QuickFilter; label: string }> = [
{ key: 'all', label: 'All' },
{ key: 'onramp', label: 'Onramp' },
{ key: 'offramp', label: 'Offramp' },
{ key: 'billpay', label: 'Bill Pay' },
{ key: 'failed', label: 'Failed' },
]