From 5b73bf4805c6abbd048183a57c44f6584cc3f43b Mon Sep 17 00:00:00 2001 From: AdaBliss Date: Sat, 29 Aug 2026 03:26:07 -0700 Subject: [PATCH] Test: Implement Component: Transaction History (List View) Empty States Add the cross-border transaction history list view and its component tests. No transaction history component existed in the codebase, so this adds the presentational component under test alongside the suite. The list renders four mutually exclusive states. The empty state is split in two on purpose: a first-time user with no transfers gets a friendly invitation to send one, while a user whose filters exclude everything gets a distinct message and a way back to the full history. A blank panel in either case would leave the user unsure whether the app had failed. - types/transactionHistory: CrossBorderTransaction shape - lib/transactionFormatters: amount, escrow status, corridor and date formatting, using ISO codes rather than locale symbols so output does not vary with the host ICU build - 20 tests covering both empty states, loading, error, retry and the populated list --- .../components/TransactionHistoryList.tsx | 177 +++++++++++ .../__tests__/TransactionHistoryList.test.tsx | 287 ++++++++++++++++++ lib/transactionFormatters.ts | 102 +++++++ types/transactionHistory.ts | 34 +++ 4 files changed, 600 insertions(+) create mode 100644 features/transactions/components/TransactionHistoryList.tsx create mode 100644 features/transactions/components/__tests__/TransactionHistoryList.test.tsx create mode 100644 lib/transactionFormatters.ts create mode 100644 types/transactionHistory.ts diff --git a/features/transactions/components/TransactionHistoryList.tsx b/features/transactions/components/TransactionHistoryList.tsx new file mode 100644 index 0000000..245d6cf --- /dev/null +++ b/features/transactions/components/TransactionHistoryList.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { ArrowLeftRight, SearchX, Send } from 'lucide-react'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { + escrowStatusStyle, + formatCorridor, + formatEscrowStatus, + formatFiatAmount, + formatSignedAssetAmount, + formatTransactionDate, +} from '@/lib/transactionFormatters'; +import type { CrossBorderTransaction } from '@/types/transactionHistory'; + +interface TransactionHistoryListProps { + transactions: CrossBorderTransaction[]; + isLoading?: boolean; + /** Message to surface when the history could not be loaded. */ + error?: string | null; + /** + * Whether a search/filter is narrowing the list. Distinguishes "you have no + * transactions yet" from "nothing matched", which need different copy and + * different calls to action. + */ + hasActiveFilters?: boolean; + onClearFilters?: () => void; + onCreateTransfer?: () => void; + onRetry?: () => void; +} + +/** + * Chronological list view of a user's cross-border transactions. + * + * Renders four mutually exclusive states: loading, error, empty, and populated. + * The empty state is deliberately split in two, because a first-time user needs + * an invitation to send a transfer while a user who has over-filtered needs a + * way back to the full list. + */ +export function TransactionHistoryList({ + transactions, + isLoading = false, + error = null, + hasActiveFilters = false, + onClearFilters, + onCreateTransfer, + onRetry, +}: TransactionHistoryListProps) { + if (isLoading) { + return ( +
+ {[0, 1, 2].map((row) => ( +
+ ))} +
+ ); + } + + if (error) { + return ( +
+

{error}

+ {onRetry && ( + + )} +
+ ); + } + + if (transactions.length === 0) { + if (hasActiveFilters) { + return ( + + ); + } + + return ( + + ); + } + + return ( +
    + {transactions.map((transaction) => ( +
  • +
    +
    +

    +

    +

    + {transaction.reference} ·{' '} + {formatCorridor( + transaction.originCountry, + transaction.destinationCountry, + )} +

    +

    + {formatTransactionDate(transaction.createdAt)} +

    +
    + +
    +

    + {formatSignedAssetAmount( + transaction.amount, + transaction.assetCode, + transaction.direction, + )} +

    +

    + {formatFiatAmount( + transaction.fiatAmount, + transaction.fiatCurrency, + )} +

    + + {formatEscrowStatus(transaction.escrowStatus)} + +
    +
    +
  • + ))} +
+ ); +} diff --git a/features/transactions/components/__tests__/TransactionHistoryList.test.tsx b/features/transactions/components/__tests__/TransactionHistoryList.test.tsx new file mode 100644 index 0000000..151acdf --- /dev/null +++ b/features/transactions/components/__tests__/TransactionHistoryList.test.tsx @@ -0,0 +1,287 @@ +/** + * TransactionHistoryList component tests. + * + * The focus is the empty states: a first-time user with no cross-border + * transactions must get a friendly, actionable message rather than a blank + * panel, and an over-filtered list must offer a way back. Loading, error and + * populated states are covered so the four states stay mutually exclusive. + */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TransactionHistoryList } from '@/features/transactions/components/TransactionHistoryList'; +import type { CrossBorderTransaction } from '@/types/transactionHistory'; + +function transaction( + overrides: Partial = {}, +): CrossBorderTransaction { + return { + id: 'tx-1', + reference: 'SC-20260425-0001', + direction: 'SENT', + counterparty: 'Amara Okafor', + amount: 1250.5, + assetCode: 'XLM', + fiatAmount: 640.25, + fiatCurrency: 'USD', + originCountry: 'NG', + destinationCountry: 'GB', + escrowStatus: 'LOCKED', + createdAt: '2026-04-25T12:00:00.000Z', + ...overrides, + }; +} + +function rows() { + return screen.queryAllByTestId('transaction-row'); +} + +describe('TransactionHistoryList', () => { + describe('empty state - no transactions at all', () => { + it('renders a friendly empty state instead of an empty list', () => { + render(); + + expect( + screen.getByText('No cross-border transactions yet'), + ).toBeInTheDocument(); + expect( + screen.getByText(/Once you send or receive your first cross-border transfer/i), + ).toBeInTheDocument(); + expect(rows()).toHaveLength(0); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + + it('offers a call to action that starts the first transfer', async () => { + const user = userEvent.setup(); + const onCreateTransfer = jest.fn(); + + render( + , + ); + + await user.click( + screen.getByRole('button', { name: 'Send your first transfer' }), + ); + + expect(onCreateTransfer).toHaveBeenCalledTimes(1); + }); + + it('omits the call to action when no handler is supplied', () => { + render(); + + expect( + screen.getByText('No cross-border transactions yet'), + ).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('does not show the filtered empty state copy', () => { + render(); + + expect( + screen.queryByText('No transactions match your filters'), + ).not.toBeInTheDocument(); + }); + }); + + describe('empty state - filters exclude everything', () => { + it('explains that filters, not the account, are the reason', () => { + render(); + + expect( + screen.getByText('No transactions match your filters'), + ).toBeInTheDocument(); + expect( + screen.queryByText('No cross-border transactions yet'), + ).not.toBeInTheDocument(); + }); + + it('offers a way back to the full history', async () => { + const user = userEvent.setup(); + const onClearFilters = jest.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Clear filters' })); + + expect(onClearFilters).toHaveBeenCalledTimes(1); + }); + + it('ignores the filtered variant once results exist again', () => { + render( + , + ); + + expect( + screen.queryByText('No transactions match your filters'), + ).not.toBeInTheDocument(); + expect(rows()).toHaveLength(1); + }); + }); + + describe('loading state', () => { + it('shows skeleton placeholders and no empty state', () => { + render(); + + expect(screen.getAllByTestId('transaction-skeleton')).toHaveLength(3); + expect( + screen.queryByText('No cross-border transactions yet'), + ).not.toBeInTheDocument(); + }); + + it('announces loading to assistive technology', () => { + render(); + + const status = screen.getByRole('status'); + expect(status).toHaveAttribute('aria-label', 'Loading transaction history'); + expect(status).toHaveAttribute('aria-live', 'polite'); + }); + + it('takes precedence over an error so states never stack', () => { + render( + , + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('transaction-skeleton')).toHaveLength(3); + }); + }); + + describe('error state', () => { + it('surfaces the failure message as an alert', () => { + render( + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Network unreachable'); + expect( + screen.queryByText('No cross-border transactions yet'), + ).not.toBeInTheDocument(); + }); + + it('lets the user retry the fetch', async () => { + const user = userEvent.setup(); + const onRetry = jest.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Try again' })); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('hides the retry button when no handler is supplied', () => { + render( + , + ); + + expect( + screen.queryByRole('button', { name: 'Try again' }), + ).not.toBeInTheDocument(); + }); + + it('still reports the error even when transactions are cached', () => { + render( + , + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(rows()).toHaveLength(0); + }); + }); + + describe('populated list', () => { + it('renders one row per transaction in the given order', () => { + render( + , + ); + + const rendered = rows(); + expect(rendered).toHaveLength(2); + expect(rendered[0]).toHaveTextContent('Amara Okafor'); + expect(rendered[1]).toHaveTextContent('Wei Zhang'); + }); + + it('labels the list for assistive technology', () => { + render(); + + expect( + screen.getByRole('list', { name: 'Cross-border transactions' }), + ).toBeInTheDocument(); + }); + + it('shows the reference, corridor and escrow status of a row', () => { + render(); + + const row = within(rows()[0]); + expect(row.getByText(/SC-20260425-0001/)).toBeInTheDocument(); + expect(row.getByText(/NG → GB/)).toBeInTheDocument(); + expect(row.getByText('Locked in escrow')).toBeInTheDocument(); + }); + + it('signs the amount according to the transfer direction', () => { + render( + , + ); + + expect(rows()[0]).toHaveTextContent('-1,250.50 XLM'); + expect(rows()[1]).toHaveTextContent('+800.00 XLM'); + }); + + it('renders a placeholder when a corridor has no fiat quote', () => { + render( + , + ); + + expect(rows()[0]).toHaveTextContent('—'); + }); + + it('renders a single-item history without falling back to the empty state', () => { + render(); + + expect(rows()).toHaveLength(1); + expect( + screen.queryByText('No cross-border transactions yet'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/lib/transactionFormatters.ts b/lib/transactionFormatters.ts new file mode 100644 index 0000000..6bae4c4 --- /dev/null +++ b/lib/transactionFormatters.ts @@ -0,0 +1,102 @@ +/** + * Display formatting for cross-border transaction history. + * + * Amounts are rendered as a locale-grouped number followed by the ISO asset or + * currency code rather than a localised symbol. Corridors span currencies whose + * symbols collide (several use "$"), and `Intl` currency symbols also vary with + * the host ICU build, which would make the UI inconsistent across environments. + */ + +import type { EscrowStatus } from '@/types/status'; +import type { TransactionDirection } from '@/types/transactionHistory'; + +/** Stellar assets carry up to 7 decimal places. */ +const MAX_ASSET_DECIMALS = 7; +const FIAT_DECIMALS = 2; + +const assetFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: FIAT_DECIMALS, + maximumFractionDigits: MAX_ASSET_DECIMALS, +}); + +const fiatFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: FIAT_DECIMALS, + maximumFractionDigits: FIAT_DECIMALS, +}); + +const ESCROW_STATUS_LABELS: Record = { + LOCKED: 'Locked in escrow', + RELEASED: 'Released', + DISPUTED: 'Disputed', + NOT_LOCKED: 'Not locked', +}; + +export const ESCROW_STATUS_STYLES: Record = { + LOCKED: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', + RELEASED: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + DISPUTED: + 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + NOT_LOCKED: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', +}; + +const UNKNOWN_AMOUNT = '—'; + +/** + * Formats a settlement amount, e.g. `1234.5, "XLM"` becomes `1,234.50 XLM`. + * Non-finite amounts render as an em dash so a bad feed cannot print "NaN". + */ +export function formatAssetAmount(amount: number, assetCode: string): string { + if (!Number.isFinite(amount)) return UNKNOWN_AMOUNT; + return `${assetFormatter.format(amount)} ${assetCode}`; +} + +/** + * Formats the local-currency leg of a transfer, e.g. `1,234.50 NGN`. + * Returns an em dash when the corridor has no fiat quote attached. + */ +export function formatFiatAmount( + amount: number | undefined, + currency: string | undefined, +): string { + if (typeof amount !== 'number' || !Number.isFinite(amount) || !currency) { + return UNKNOWN_AMOUNT; + } + return `${fiatFormatter.format(amount)} ${currency}`; +} + +/** Prefixes an amount with the direction sign a user expects on a ledger. */ +export function formatSignedAssetAmount( + amount: number, + assetCode: string, + direction: TransactionDirection, +): string { + const formatted = formatAssetAmount(amount, assetCode); + if (formatted === UNKNOWN_AMOUNT) return formatted; + return `${direction === 'SENT' ? '-' : '+'}${formatted}`; +} + +/** Turns the wire-format escrow status into sentence-case display text. */ +export function formatEscrowStatus(status: EscrowStatus): string { + return ESCROW_STATUS_LABELS[status] ?? 'Unknown'; +} + +/** Tailwind classes for an escrow status badge, with a neutral fallback. */ +export function escrowStatusStyle(status: EscrowStatus): string { + return ESCROW_STATUS_STYLES[status] ?? ESCROW_STATUS_STYLES.NOT_LOCKED; +} + +/** Renders a corridor as `NG -> GB`, using an arrow the terminal-safe way. */ +export function formatCorridor(origin: string, destination: string): string { + return `${origin} → ${destination}`; +} + +/** Short, unambiguous date for a history row, e.g. `Apr 25, 2026`. */ +export function formatTransactionDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return UNKNOWN_AMOUNT; + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} diff --git a/types/transactionHistory.ts b/types/transactionHistory.ts new file mode 100644 index 0000000..772462a --- /dev/null +++ b/types/transactionHistory.ts @@ -0,0 +1,34 @@ +/** + * Cross-border transaction history types. + * + * Shared by the list and grid presentations of a user's settled and in-flight + * cross-border transfers. + */ + +import type { EscrowStatus } from './status'; + +export type TransactionDirection = 'SENT' | 'RECEIVED'; + +export interface CrossBorderTransaction { + id: string; + /** Human-facing reference shown to the user, e.g. "SC-20260425-0001". */ + reference: string; + direction: TransactionDirection; + /** Display name of the other party in the corridor. */ + counterparty: string; + /** Amount in the settlement asset. */ + amount: number; + /** Settlement asset code, e.g. "XLM" or "USDC". */ + assetCode: string; + /** Quoted amount in the corridor's local fiat currency, when available. */ + fiatAmount?: number; + /** ISO 4217 code for {@link fiatAmount}. */ + fiatCurrency?: string; + /** ISO 3166-1 alpha-2 origin country. */ + originCountry: string; + /** ISO 3166-1 alpha-2 destination country. */ + destinationCountry: string; + escrowStatus: EscrowStatus; + /** ISO 8601 creation timestamp. */ + createdAt: string; +}