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; +}