diff --git a/features/transactions/components/TransactionHistoryGrid.tsx b/features/transactions/components/TransactionHistoryGrid.tsx new file mode 100644 index 0000000..feef7a3 --- /dev/null +++ b/features/transactions/components/TransactionHistoryGrid.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { Inbox } 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 TransactionHistoryGridProps { + transactions: CrossBorderTransaction[]; + onSelect?: (_transaction: CrossBorderTransaction) => void; +} + +/** + * Card grid view of a user's cross-border transactions. + * + * The same data as the list view, laid out as scannable cards. Every displayed + * value goes through the shared formatters so a card and a row can never + * disagree about how an amount or an escrow status reads. + */ +export function TransactionHistoryGrid({ + transactions, + onSelect, +}: TransactionHistoryGridProps) { + if (transactions.length === 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx b/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx new file mode 100644 index 0000000..fab4875 --- /dev/null +++ b/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx @@ -0,0 +1,300 @@ +/** + * TransactionHistoryGrid component tests. + * + * The focus is data formatting inside the cards: currency amounts must be + * grouped, signed by direction and shown with their asset/ISO code, and escrow + * statuses must render as human-readable badges rather than raw wire values. + */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TransactionHistoryGrid } from '@/features/transactions/components/TransactionHistoryGrid'; +import type { CrossBorderTransaction } from '@/types/transactionHistory'; +import type { EscrowStatus } from '@/types/status'; + +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 cards() { + return screen.queryAllByTestId('transaction-card'); +} + +function firstCard() { + return within(cards()[0]); +} + +describe('TransactionHistoryGrid', () => { + describe('currency amount formatting', () => { + it('groups thousands and pads to two decimals', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-amount')).toHaveTextContent( + '-1,250.50 XLM', + ); + }); + + it('marks outgoing transfers with a minus and incoming with a plus', () => { + render( + , + ); + + const amounts = screen + .getAllByTestId('card-amount') + .map((el) => el.textContent); + expect(amounts).toEqual(['-1,250.50 XLM', '+800.00 XLM']); + }); + + it('shows the settlement asset code alongside the amount', () => { + render( + , + ); + + const amounts = screen + .getAllByTestId('card-amount') + .map((el) => el.textContent); + expect(amounts).toEqual(['-500.00 USDC', '-500.00 XLM']); + }); + + it('keeps stellar precision for sub-cent amounts', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-amount')).toHaveTextContent( + '+0.0000001 XLM', + ); + }); + + it('formats a large amount without scientific notation', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-amount')).toHaveTextContent( + '+12,345,678.90 XLM', + ); + }); + + it('renders the local currency leg with its ISO code', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent( + '1,234,567.80 NGN', + ); + }); + + it('renders a placeholder when no fiat quote is attached', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent('—'); + }); + + it('never prints NaN when the feed sends a bad amount', () => { + render( + , + ); + + const amount = firstCard().getByTestId('card-amount'); + expect(amount).toHaveTextContent('—'); + expect(amount.textContent).not.toMatch(/NaN/); + }); + + it('renders a zero-value transfer as a real amount', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-amount')).toHaveTextContent( + '-0.00 XLM', + ); + expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent( + '0.00 USD', + ); + }); + }); + + describe('escrow status formatting', () => { + it.each<[EscrowStatus, string]>([ + ['LOCKED', 'Locked in escrow'], + ['RELEASED', 'Released'], + ['DISPUTED', 'Disputed'], + ['NOT_LOCKED', 'Not locked'], + ])('renders %s as the readable label %s', (escrowStatus, expected) => { + render( + , + ); + + expect(firstCard().getByTestId('card-escrow-status')).toHaveTextContent( + expected, + ); + }); + + it('never shows the raw underscored wire value', () => { + render( + , + ); + + expect(cards()[0].textContent).not.toContain('NOT_LOCKED'); + }); + + it('gives the badge an accessible label', () => { + render( + , + ); + + expect( + screen.getByLabelText('Escrow status: Disputed'), + ).toBeInTheDocument(); + }); + + it('styles each status distinctly so they are visually separable', () => { + render( + , + ); + + const classNames = screen + .getAllByTestId('card-escrow-status') + .map((el) => el.className); + expect(new Set(classNames).size).toBe(3); + }); + }); + + describe('card content', () => { + it('renders one card per transaction in the given order', () => { + render( + , + ); + + const rendered = cards(); + expect(rendered).toHaveLength(2); + expect(rendered[0]).toHaveTextContent('Amara Okafor'); + expect(rendered[1]).toHaveTextContent('Wei Zhang'); + }); + + it('shows the reference, corridor and date', () => { + render(); + + const card = firstCard(); + expect(card.getByTestId('card-reference')).toHaveTextContent( + 'SC-20260425-0001', + ); + expect(card.getByTestId('card-corridor')).toHaveTextContent('NG → GB'); + expect(card.getByTestId('card-date')).toHaveTextContent('Apr 25, 2026'); + }); + + it('renders a placeholder for an unparseable timestamp', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-date')).toHaveTextContent('—'); + }); + + it('labels the grid for assistive technology', () => { + render(); + + expect( + screen.getByRole('list', { name: 'Cross-border transactions' }), + ).toBeInTheDocument(); + }); + + it('passes the selected transaction to the details handler', async () => { + const user = userEvent.setup(); + const onSelect = jest.fn(); + const tx = transaction({ id: 'tx-9' }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'View details' })); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(tx); + }); + + it('omits the details action when no handler is supplied', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + }); + + describe('empty state', () => { + it('renders an empty state instead of a bare grid', () => { + render(); + + expect( + screen.getByText('No cross-border transactions yet'), + ).toBeInTheDocument(); + expect(cards()).toHaveLength(0); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/lib/__tests__/transactionFormatters.test.ts b/lib/__tests__/transactionFormatters.test.ts new file mode 100644 index 0000000..94dd7ac --- /dev/null +++ b/lib/__tests__/transactionFormatters.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for the cross-border transaction display formatters. + * + * These are the single source of truth for how amounts and escrow statuses + * read in both the grid and list views, so they are pinned down directly as + * well as through the component tests. + */ + +import { + escrowStatusStyle, + ESCROW_STATUS_STYLES, + formatAssetAmount, + formatCorridor, + formatEscrowStatus, + formatFiatAmount, + formatSignedAssetAmount, + formatTransactionDate, +} from '@/lib/transactionFormatters'; +import type { EscrowStatus } from '@/types/status'; + +describe('formatAssetAmount', () => { + it('groups thousands and pads to two decimals', () => { + expect(formatAssetAmount(1250.5, 'XLM')).toBe('1,250.50 XLM'); + expect(formatAssetAmount(1000000, 'USDC')).toBe('1,000,000.00 USDC'); + }); + + it('keeps stellar precision up to seven decimal places', () => { + expect(formatAssetAmount(0.0000001, 'XLM')).toBe('0.0000001 XLM'); + expect(formatAssetAmount(12.1234567, 'XLM')).toBe('12.1234567 XLM'); + }); + + it('rounds beyond seven decimal places rather than overflowing the cell', () => { + expect(formatAssetAmount(1.123456789, 'XLM')).toBe('1.1234568 XLM'); + }); + + it('formats zero as a real amount, not a placeholder', () => { + expect(formatAssetAmount(0, 'XLM')).toBe('0.00 XLM'); + }); + + it('preserves the sign of a negative amount', () => { + expect(formatAssetAmount(-42.5, 'XLM')).toBe('-42.50 XLM'); + }); + + it('renders a placeholder instead of NaN or Infinity', () => { + expect(formatAssetAmount(Number.NaN, 'XLM')).toBe('—'); + expect(formatAssetAmount(Number.POSITIVE_INFINITY, 'XLM')).toBe('—'); + }); +}); + +describe('formatFiatAmount', () => { + it('renders the amount with its ISO currency code', () => { + expect(formatFiatAmount(640.25, 'USD')).toBe('640.25 USD'); + expect(formatFiatAmount(1234567.8, 'NGN')).toBe('1,234,567.80 NGN'); + }); + + it('always shows exactly two decimals', () => { + expect(formatFiatAmount(10, 'EUR')).toBe('10.00 EUR'); + expect(formatFiatAmount(10.129, 'EUR')).toBe('10.13 EUR'); + }); + + it('returns a placeholder when the corridor has no fiat quote', () => { + expect(formatFiatAmount(undefined, 'USD')).toBe('—'); + expect(formatFiatAmount(640.25, undefined)).toBe('—'); + expect(formatFiatAmount(undefined, undefined)).toBe('—'); + }); + + it('returns a placeholder for a non-finite amount', () => { + expect(formatFiatAmount(Number.NaN, 'USD')).toBe('—'); + }); + + it('treats a zero quote as a real amount', () => { + expect(formatFiatAmount(0, 'USD')).toBe('0.00 USD'); + }); +}); + +describe('formatSignedAssetAmount', () => { + it('prefixes outgoing transfers with a minus sign', () => { + expect(formatSignedAssetAmount(1250.5, 'XLM', 'SENT')).toBe('-1,250.50 XLM'); + }); + + it('prefixes incoming transfers with a plus sign', () => { + expect(formatSignedAssetAmount(800, 'XLM', 'RECEIVED')).toBe('+800.00 XLM'); + }); + + it('signs a zero amount by direction rather than dropping the sign', () => { + expect(formatSignedAssetAmount(0, 'USDC', 'SENT')).toBe('-0.00 USDC'); + expect(formatSignedAssetAmount(0, 'USDC', 'RECEIVED')).toBe('+0.00 USDC'); + }); + + it('does not sign the placeholder for a non-finite amount', () => { + expect(formatSignedAssetAmount(Number.NaN, 'XLM', 'SENT')).toBe('—'); + }); +}); + +describe('formatEscrowStatus', () => { + it.each<[EscrowStatus, string]>([ + ['LOCKED', 'Locked in escrow'], + ['RELEASED', 'Released'], + ['DISPUTED', 'Disputed'], + ['NOT_LOCKED', 'Not locked'], + ])('renders %s as %s', (status, expected) => { + expect(formatEscrowStatus(status)).toBe(expected); + }); + + it('never leaks the raw wire format to the user', () => { + const statuses: EscrowStatus[] = [ + 'LOCKED', + 'RELEASED', + 'DISPUTED', + 'NOT_LOCKED', + ]; + + for (const status of statuses) { + expect(formatEscrowStatus(status)).not.toContain('_'); + } + }); + + it('falls back to Unknown for an unrecognised status', () => { + expect(formatEscrowStatus('SOMETHING_NEW' as EscrowStatus)).toBe('Unknown'); + }); +}); + +describe('escrowStatusStyle', () => { + it('gives every known status a distinct badge style', () => { + const styles = Object.values(ESCROW_STATUS_STYLES); + expect(new Set(styles).size).toBe(styles.length); + }); + + it('returns the neutral style for an unrecognised status', () => { + expect(escrowStatusStyle('SOMETHING_NEW' as EscrowStatus)).toBe( + ESCROW_STATUS_STYLES.NOT_LOCKED, + ); + }); +}); + +describe('formatCorridor', () => { + it('renders origin and destination with a direction arrow', () => { + expect(formatCorridor('NG', 'GB')).toBe('NG → GB'); + }); +}); + +describe('formatTransactionDate', () => { + it('renders a short, unambiguous date', () => { + expect(formatTransactionDate('2026-04-25T12:00:00.000Z')).toBe( + 'Apr 25, 2026', + ); + }); + + it('returns a placeholder for an unparseable timestamp', () => { + expect(formatTransactionDate('not-a-date')).toBe('—'); + }); +}); 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; +}