diff --git a/docs/BillingHistoryPreview.md b/docs/BillingHistoryPreview.md index 5eb9df6e..96f47f77 100644 --- a/docs/BillingHistoryPreview.md +++ b/docs/BillingHistoryPreview.md @@ -39,6 +39,18 @@ The Billing History page displays a paginated table of USDC transactions. Each r 4. Press **Escape** to dismiss the card and return focus to the trigger. 5. Tab again to move to the next row (card closes automatically on blur). +### Touch user (below 640px) + +Below 640px the table is replaced by a list of transaction cards. Only one of the two layouts is in the DOM at a time, so screen readers never read both. + +1. Each card is a list item with a heading (the description), a status badge, the signed amount (`+` for credits, a minus sign for debits), the date, and a **Details** button. +2. Tap **Details** to open a bottom sheet with the same on-chain details as the hover preview: transaction hash (with a **Copy** button), network, confirmations and timestamp. +3. Close the sheet with the close button, a tap on the backdrop, or **Escape**. Focus returns to that card's Details button. +4. Sorting uses a single **Sort by** select (date or amount, either direction). It writes the same `sort` and `dir` URL params as the desktop column headers, so a shared link sorts the same way on both layouts. +5. Filters stack in one column and behave exactly as on desktop. The net-balance summary stays at the top. + +The breakpoint is `(max-width: 639px)`. It is read in JavaScript by the `useMediaQuery` hook (to choose which layout to render) and in CSS (to stack the filters). All touch targets are at least 44x44px. + --- ## Filtering @@ -187,11 +199,12 @@ All colours in `PreviewCard` and `BillingHistory` use CSS custom properties so b ## Responsive behaviour -- The table container uses `overflow-x: auto` so horizontal scrolling is available on small viewports. -- The preview card has `maxWidth: 90vw` — it never overflows the viewport on narrow screens. -- Filter controls use `flexWrap: wrap` so they stack on small screens without clipping. +- **Below 640px:** transactions render as cards (see "Touch user" above). The table is not rendered, so there is no horizontal scrolling at 320px. +- **640px and above:** the table renders as before. Its container keeps `overflow-x: auto` as a fallback for narrow desktop windows. +- The preview card has `maxWidth: 90vw`, so it never overflows the viewport on narrow screens. +- Filter controls wrap on wider screens and stack into one column below 640px. - The page heading uses `clamp(1.35rem, 3vw, 1.75rem)` for fluid sizing. - +- Styles live in `src/pages/BillingHistory.css` (`.billing-history .bh-*` classes), not inline. --- ## Running the tests diff --git a/src/components/BillingSortSelect.test.tsx b/src/components/BillingSortSelect.test.tsx new file mode 100644 index 00000000..fa04c137 --- /dev/null +++ b/src/components/BillingSortSelect.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BillingSortSelect } from './BillingSortSelect'; + +const getSelect = () => screen.getByRole('combobox', { name: /sort transactions/i }) as HTMLSelectElement; + +describe('BillingSortSelect', () => { + afterEach(cleanup); + + it('shows a disabled "Default order" placeholder when nothing is sorted', () => { + render(); + expect(getSelect().value).toBe(''); + expect(screen.getByRole('option', { name: 'Default order' })).toBeTruthy(); + }); + + it('offers the four date/amount options and no placeholder once sorted', () => { + render(); + expect(getSelect().value).toBe('amount:desc'); + expect(screen.getAllByRole('option')).toHaveLength(4); + expect(screen.queryByRole('option', { name: 'Default order' })).toBeNull(); + }); + + it('reports the chosen column and direction', () => { + const onChange = vi.fn(); + render(); + fireEvent.change(getSelect(), { target: { value: 'date:asc' } }); + expect(onChange).toHaveBeenCalledWith('date', 'asc'); + }); +}); diff --git a/src/components/BillingSortSelect.tsx b/src/components/BillingSortSelect.tsx new file mode 100644 index 00000000..71ee3302 --- /dev/null +++ b/src/components/BillingSortSelect.tsx @@ -0,0 +1,53 @@ +import type { SortColumn, SortDirection } from '../pages/BillingHistory'; + +interface SortOption { + column: SortColumn; + direction: SortDirection; + label: string; +} + +const SORT_OPTIONS: readonly SortOption[] = [ + { column: 'date', direction: 'desc', label: 'Date: newest first' }, + { column: 'date', direction: 'asc', label: 'Date: oldest first' }, + { column: 'amount', direction: 'desc', label: 'Amount: high to low' }, + { column: 'amount', direction: 'asc', label: 'Amount: low to high' }, +]; + +const toValue = (column: SortColumn, direction: SortDirection) => `${column}:${direction}`; + +export interface BillingSortSelectProps { + column: SortColumn | null; + direction: SortDirection; + onChange: (column: SortColumn, direction: SortDirection) => void; +} + +/** One select that replaces the sortable table headers on small screens. */ +export function BillingSortSelect({ column, direction, onChange }: BillingSortSelectProps) { + return ( + + ); +} + +export default BillingSortSelect; diff --git a/src/components/PreviewCard.tsx b/src/components/PreviewCard.tsx index b0bcc1b3..3d42c6c9 100644 --- a/src/components/PreviewCard.tsx +++ b/src/components/PreviewCard.tsx @@ -141,7 +141,7 @@ function formatUsdcAmount(value: number): string { * Format an ISO 8601 timestamp into a human-readable string. * Falls back to the raw string if parsing fails. */ -function formatTimestamp(iso: string): string { +export function formatTimestamp(iso: string): string { try { return new Intl.DateTimeFormat('en-US', { month: 'short', diff --git a/src/components/TransactionCard.test.tsx b/src/components/TransactionCard.test.tsx new file mode 100644 index 00000000..b8ab71ac --- /dev/null +++ b/src/components/TransactionCard.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TransactionCard } from './TransactionCard'; +import type { BillingTransaction } from '../pages/BillingHistory'; + +const tx: BillingTransaction = { + id: 'tx-1', + description: 'USDC vault deposit', + type: 'Deposit', + direction: 'credit', + amount: 100, + status: 'success', + timestamp: '2026-07-25T14:32:00Z', + txHash: 'A'.repeat(64), + network: 'Stellar Mainnet', + confirmations: 120, +}; + +function renderCard(overrides: Partial = {}, onDetails = vi.fn()) { + const item = { ...tx, ...overrides }; + render( +
    + +
, + ); + return { item, onDetails }; +} + +describe('TransactionCard', () => { + afterEach(cleanup); + + it('is a list item with the description as its heading', () => { + renderCard(); + expect(screen.getByRole('listitem')).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'USDC vault deposit' })).toBeTruthy(); + }); + + it('shows a plus sign for credits and a minus sign for debits', () => { + renderCard(); + expect(screen.getByText(/^\+.*USDC$/)).toBeTruthy(); + cleanup(); + renderCard({ direction: 'debit' }); + expect(screen.getByText(/^\u2212.*USDC$/)).toBeTruthy(); + }); + + it('shows the date and a text status label (not colour alone)', () => { + renderCard(); + expect(screen.getByText('Jul 25, 02:32 PM')).toBeTruthy(); + expect(screen.getByText('Confirmed')).toBeTruthy(); + }); + + it('calls onDetails with the transaction and the tapped button', () => { + const { item, onDetails } = renderCard(); + const button = screen.getByRole('button', { name: /^details/i }); + fireEvent.click(button); + expect(onDetails).toHaveBeenCalledWith(item, button); + }); + + it('labels the Details button with the date so identical descriptions stay distinguishable', () => { + renderCard(); + expect( + screen.getByRole('button', { name: 'Details for USDC vault deposit, Jul 25, 02:32 PM' }), + ).toBeTruthy(); + }); +}); diff --git a/src/components/TransactionCard.tsx b/src/components/TransactionCard.tsx new file mode 100644 index 00000000..626015b6 --- /dev/null +++ b/src/components/TransactionCard.tsx @@ -0,0 +1,40 @@ +import StatusBadge from './StatusBadge'; +import { formatUsdcAmount } from '../utils/format'; +import type { BillingTransaction } from '../pages/BillingHistory'; + +export interface TransactionCardProps { + tx: BillingTransaction; + /** Already-formatted, timezone-aware date text. */ + dateText: string; + statusLabel: string; + /** Receives the button so the caller can return focus to it later. */ + onDetails: (tx: BillingTransaction, trigger: HTMLButtonElement) => void; +} + +export function TransactionCard({ tx, dateText, statusLabel, onDetails }: TransactionCardProps) { + const sign = tx.direction === 'credit' ? '+' : '\u2212'; + return ( +
  • +
    +

    {tx.description}

    + +
    +

    + {sign}{formatUsdcAmount(tx.amount)} USDC +

    +
    + + +
    +
  • + ); +} + +export default TransactionCard; diff --git a/src/components/TransactionDetails.test.tsx b/src/components/TransactionDetails.test.tsx new file mode 100644 index 00000000..b5c39dcd --- /dev/null +++ b/src/components/TransactionDetails.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TransactionDetails } from './TransactionDetails'; +import type { PreviewCardData } from './PreviewCard'; + +const HASH = 'A3F9B2C1D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0A1'; +const data: PreviewCardData = { + id: 'tx-1', + title: 'USDC vault deposit', + txHash: HASH, + network: 'Stellar Mainnet', + confirmations: 120, + timestamp: '2026-07-25T14:32:00Z', +}; + +function stubClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + return writeText; +} + +describe('TransactionDetails', () => { + afterEach(() => { + cleanup(); + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }); + }); + + it('shows the network, confirmations, full hash and a formatted time', () => { + stubClipboard(); + render(); + expect(screen.getByText('Stellar Mainnet')).toBeTruthy(); + expect(screen.getByText('120')).toBeTruthy(); + expect(screen.getByText(HASH)).toBeTruthy(); + expect(screen.getByText(/2026/)).toBeTruthy(); + }); + + it('copies the full hash and confirms it', async () => { + const writeText = stubClipboard(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Copy transaction hash' })); + expect(await screen.findByText('Copied!')).toBeTruthy(); + expect(writeText).toHaveBeenCalledWith(HASH); + }); + + it('omits rows it has no data for', () => { + stubClipboard(); + render(); + expect(screen.queryByText('Network')).toBeNull(); + expect(screen.queryByText('Confirmations')).toBeNull(); + expect(screen.queryByRole('button', { name: /copy/i })).toBeNull(); + }); +}); diff --git a/src/components/TransactionDetails.tsx b/src/components/TransactionDetails.tsx new file mode 100644 index 00000000..df13b0c2 --- /dev/null +++ b/src/components/TransactionDetails.tsx @@ -0,0 +1,52 @@ +import { useCopy } from '../hooks/useCopy'; +import { formatTimestamp } from './PreviewCard'; +import type { PreviewCardData } from './PreviewCard'; + +/** On-chain details for one transaction: the same PreviewCardData the desktop hover preview shows. */ +export function TransactionDetails({ data }: { data: PreviewCardData }) { + const { copied, supported, handleCopy } = useCopy(); + return ( +
    + {data.network && ( + <> +
    Network
    +
    {data.network}
    + + )} + {data.confirmations !== undefined && ( + <> +
    Confirmations
    +
    {data.confirmations}
    + + )} + {data.txHash && ( + <> +
    Transaction hash
    +
    + {data.txHash} + {supported && ( + + )} +
    + + )} + {data.timestamp && ( + <> +
    Time
    +
    + +
    + + )} +
    + ); +} + +export default TransactionDetails; diff --git a/src/data/a11y-manifest.json b/src/data/a11y-manifest.json index 6a788ee7..6649e2fe 100644 --- a/src/data/a11y-manifest.json +++ b/src/data/a11y-manifest.json @@ -1,6 +1,7 @@ { "components": [ { "id": "ApiCard", "name": "ApiCard", "status": "audited" }, + { "id": "BillingSortSelect", "name": "BillingSortSelect", "status": "audited" }, { "id": "Breadcrumb", "name": "Breadcrumb", "status": "needs-work" }, { "id": "CodeExample", "name": "CodeExample", "status": "audited" }, { "id": "Dashboard", "name": "Dashboard", "status": "n/a" }, @@ -14,6 +15,8 @@ { "id": "SearchBar", "name": "SearchBar", "status": "audited" }, { "id": "ServerError", "name": "ServerError", "status": "n/a" }, { "id": "ServerErrorDemo", "name": "ServerErrorDemo", "status": "n/a" }, - { "id": "Skeleton", "name": "Skeleton", "status": "audited" } + { "id": "Skeleton", "name": "Skeleton", "status": "audited" }, + { "id": "TransactionCard", "name": "TransactionCard", "status": "audited" }, + { "id": "TransactionDetails", "name": "TransactionDetails", "status": "audited" } ] } diff --git a/src/hooks/useMediaQuery.test.ts b/src/hooks/useMediaQuery.test.ts new file mode 100644 index 00000000..a274463d --- /dev/null +++ b/src/hooks/useMediaQuery.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useMediaQuery } from './useMediaQuery'; + +type Listener = (e: { matches: boolean }) => void; +const original = window.matchMedia; + +function mockMatchMedia(initial: boolean) { + const listeners = new Set(); + const removeEventListener = vi.fn((_t: string, cb: Listener) => listeners.delete(cb)); + const mql = { + matches: initial, + media: '', + addEventListener: (_t: string, cb: Listener) => listeners.add(cb), + removeEventListener, + }; + window.matchMedia = (() => mql) as unknown as typeof window.matchMedia; + const emit = (matches: boolean) => { + mql.matches = matches; + listeners.forEach((cb) => cb({ matches })); + }; + return { emit, removeEventListener }; +} + +describe('useMediaQuery', () => { + afterEach(() => { window.matchMedia = original; }); + + it('returns the current match state', () => { + mockMatchMedia(true); + const { result } = renderHook(() => useMediaQuery('(max-width: 639px)')); + expect(result.current).toBe(true); + }); + + it('updates when the media query changes', () => { + const { emit } = mockMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(max-width: 639px)')); + expect(result.current).toBe(false); + act(() => emit(true)); + expect(result.current).toBe(true); + }); + + it('stops listening on unmount', () => { + const { removeEventListener } = mockMatchMedia(false); + const { unmount } = renderHook(() => useMediaQuery('(max-width: 639px)')); + unmount(); + expect(removeEventListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..ae4e4173 --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -0,0 +1,16 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +/** Returns whether the given CSS media query currently matches, live-updating. */ +export function useMediaQuery(query: string): boolean { + const subscribe = useCallback( + (notify: () => void) => { + const mql = window.matchMedia(query); + mql.addEventListener('change', notify); + return () => mql.removeEventListener('change', notify); + }, + [query], + ); + return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false); +} + +export default useMediaQuery; diff --git a/src/pages/BillingHistory.css b/src/pages/BillingHistory.css new file mode 100644 index 00000000..9772c24a --- /dev/null +++ b/src/pages/BillingHistory.css @@ -0,0 +1,298 @@ +/* BillingHistory.css — class-based styles extracted 1:1 from the former + inline styles. Every selector is scoped under .billing-history (0,2,0) + so it keeps beating global rules in index.css, as inline styles did. */ + +/* Two classes so this beats the legacy `.billing-history { max-width: 960px }` + in index.css regardless of stylesheet order (inline 1000px used to win). */ +.billing-history.bh-page { padding: 24px; max-width: 1000px; margin: 0 auto; } + +.billing-history .bh-header { margin-bottom: 20px; } +.billing-history .bh-header .eyebrow { margin-bottom: 4px; } + +.billing-history .bh-title { + font-size: clamp(1.35rem, 3vw, 1.75rem); + font-weight: 700; + margin: 0 0 6px; + color: var(--text-primary, var(--text, #f9fafb)); +} + +.billing-history .bh-subtitle { + color: var(--text-secondary, #9ca3af); + font-size: 0.875rem; + margin: 0; +} + +.billing-history .bh-kbd { + font-family: monospace; + font-size: 0.75rem; + padding: 1px 4px; + border-radius: 3px; + border: 1px solid var(--line, rgba(255, 255, 255, 0.15)); +} + +/* Visually hidden, still announced by screen readers (live region). */ +.billing-history .bh-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* ── Filters ── */ +.billing-history .bh-filters { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 16px; + align-items: center; +} +.billing-history .bh-field { display: flex; flex-direction: column; gap: 2px; } +.billing-history .bh-field--grow { flex: 1 1 180px; } +.billing-history .bh-field__label { + font-size: 0.7rem; + color: var(--text-secondary, #9ca3af); + font-weight: 600; +} +.billing-history .bh-field__control { + padding: 5px 8px; + border-radius: 6px; + border: 1px solid var(--line, rgba(255, 255, 255, 0.15)); + background: var(--bg-chip, rgba(255, 255, 255, 0.04)); + color: var(--text-primary, #f9fafb); + font-size: 0.8125rem; +} + +/* ── Summary strip ── */ +.billing-history .bh-summary { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + font-size: 0.8125rem; + color: var(--text-secondary, #9ca3af); +} +.billing-history .bh-net--positive { color: var(--success, #10b981); } +.billing-history .bh-net--negative { color: var(--danger, #ef4444); } +.billing-history .bh-net--zero { color: var(--text-primary, #f9fafb); } + +/* ── Empty state ── */ +.billing-history .bh-empty { + padding: 40px 24px; + text-align: center; + color: var(--text-secondary, #9ca3af); + border-radius: 10px; + border: 1px dashed var(--line, rgba(255, 255, 255, 0.1)); +} + +/* ── Table ── */ +.billing-history .bh-table-wrap { + overflow-x: auto; + border-radius: 10px; + border: 1px solid var(--line, rgba(255, 255, 255, 0.08)); +} +.billing-history .bh-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} +.billing-history .bh-table__head-row { + background: var(--bg-chip, rgba(255, 255, 255, 0.03)); + border-bottom: 1px solid var(--line, rgba(255, 255, 255, 0.08)); +} +.billing-history .bh-table tbody tr:not(:last-child) { + border-bottom: 1px solid var(--line, rgba(255, 255, 255, 0.05)); +} + +.billing-history .bh-th { + padding: 10px 12px; + text-align: left; + font-weight: 600; + color: var(--text-secondary, #9ca3af); + white-space: nowrap; +} +.billing-history .bh-th--date { width: 160px; } +.billing-history .bh-th--description { width: auto; } +.billing-history .bh-th--narrow { width: 110px; } +.billing-history .bh-th--amount { text-align: right; width: 130px; } + +.billing-history .bh-sort-btn { + background: none; + border: none; + padding: 0; + cursor: pointer; + font: inherit; + font-weight: 600; + color: var(--text-secondary, #9ca3af); + display: inline-flex; + align-items: center; + gap: 4px; +} +.billing-history .bh-sort-btn--active { color: var(--accent, #6366f1); } +.billing-history .bh-sort-icon { font-size: 0.65rem; line-height: 1; } + +.billing-history .bh-td { padding: 10px 12px; } +.billing-history .bh-td--date { + white-space: nowrap; + color: var(--text-secondary, #9ca3af); + font-size: 0.75rem; +} +.billing-history .bh-td--type { color: var(--text-secondary, #9ca3af); } +.billing-history .bh-td--amount { text-align: right; font-weight: 600; white-space: nowrap; } +.billing-history .bh-td--hash { + font-family: monospace; + font-size: 0.7rem; + color: var(--accent, #6366f1); +} + +.billing-history .bh-description { + color: var(--text-primary, #f9fafb); + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 6px; +} +.billing-history .bh-direction-icon { font-size: 0.75rem; } + +/* Credit / debit colouring shared by the direction arrow and the amount. */ +.billing-history .bh-tone--credit { color: var(--success, #10b981); } +.billing-history .bh-tone--debit { color: var(--danger, #ef4444); } + +/* ── Keyboard tip ── */ +.billing-history .bh-tip { + margin-top: 14px; + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); +} + +/* ── Transaction cards (rendered only below 640px) ──────────────────────── */ + +.billing-history .bh-card-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.billing-history .bh-card { + min-width: 0; + padding: 14px; + border-radius: 10px; + border: 1px solid var(--line, rgba(255, 255, 255, 0.08)); + background: var(--bg-chip, rgba(255, 255, 255, 0.03)); +} + +.billing-history .bh-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.billing-history .bh-card__top > :last-child { + flex-shrink: 0; +} + +.billing-history .bh-card__title { + min-width: 0; + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary, #f9fafb); + overflow-wrap: anywhere; +} + +.billing-history .bh-card__amount { + margin: 8px 0 0; + font-size: 1.125rem; + font-weight: 700; +} + +.billing-history .bh-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 10px; + font-size: 0.75rem; + color: var(--text-secondary, #9ca3af); +} + +/* Shared look for the tappable buttons (44 x 44px minimum touch target). */ +.billing-history .bh-card__details, +.billing-history .bh-details__copy { + min-width: 44px; + min-height: 44px; + padding: 0 16px; + border-radius: 8px; + border: 1px solid var(--line, rgba(255, 255, 255, 0.15)); + background: none; + color: var(--accent, #6366f1); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +/* ── Details sheet content ──────────────────────────────────────────────── */ + +.billing-history .bh-details { + margin: 0; +} + +.billing-history .bh-details dt { + margin-top: 14px; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #9ca3af); +} + +.billing-history .bh-details dd { + margin: 4px 0 0; + color: var(--text-primary, #f9fafb); + overflow-wrap: anywhere; +} + +.billing-history .bh-details__hash { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.billing-history .bh-details__hash code { + font-family: monospace; + font-size: 0.8125rem; + word-break: break-all; +} + +/* ── Small screens: stacked controls, no sideways scrolling ─────────────── */ + +@media (max-width: 639px) { + .billing-history.bh-page { + padding: 16px; + } + + .billing-history .bh-filters { + flex-direction: column; + align-items: stretch; + } + + /* The 180px flex-basis would become a height in a column layout. */ + .billing-history .bh-field--grow { + flex: 0 0 auto; + } + + /* 16px text stops iOS Safari from zooming the page when a control is focused. */ + .billing-history .bh-field__control { + min-height: 44px; + font-size: 1rem; + } + + .billing-history .bh-summary { + flex-wrap: wrap; + gap: 4px 12px; + } +} diff --git a/src/pages/BillingHistory.test.tsx b/src/pages/BillingHistory.test.tsx index 2cb8044a..ce93b44e 100644 --- a/src/pages/BillingHistory.test.tsx +++ b/src/pages/BillingHistory.test.tsx @@ -15,11 +15,11 @@ * - Tx hash truncation in the table */ -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { AccountProvider } from '../hooks/useAccountContext'; import { addAccount, switchAccount, _reset } from '../state/accountStore'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { BillingHistory, MOCK_TRANSACTIONS } from './BillingHistory'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -607,3 +607,238 @@ describe('BillingHistory — column sorting', () => { expect(amountTh?.getAttribute('aria-sort')).toBe('descending'); }); }); + +// ── Responsive layout (issue #1320) ─────────────────────────────────────────── + +const MOBILE_QUERY = '(max-width: 639px)'; +const originalMatchMedia = window.matchMedia; + +/** Make window.matchMedia report a phone-width or desktop-width viewport. */ +function setViewport(mode: 'mobile' | 'desktop') { + window.matchMedia = ((query: string) => ({ + matches: mode === 'mobile' && query === MOBILE_QUERY, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + +describe('BillingHistory — responsive layout', () => { + afterEach(() => { + cleanup(); + _reset(); + window.matchMedia = originalMatchMedia; + }); + + it('mobile: renders a card list and no table', () => { + setViewport('mobile'); + renderBH(); + expect(screen.getByRole('list', { name: /billing transactions/i })).toBeTruthy(); + expect(screen.queryByRole('table')).toBeNull(); + }); + + it('desktop: renders the table and no card list', () => { + setViewport('desktop'); + renderBH(); + expect(screen.getByRole('table')).toBeTruthy(); + expect(screen.queryByRole('list', { name: /billing transactions/i })).toBeNull(); + }); +}); + +describe('BillingHistory — mobile cards', () => { + afterEach(() => { cleanup(); _reset(); window.matchMedia = originalMatchMedia; }); + + function getCards() { + return within(screen.getByRole('list', { name: /billing transactions/i })).getAllByRole('listitem'); + } + + it('renders one card per transaction', () => { + setViewport('mobile'); + renderBH(); + expect(getCards()).toHaveLength(MOCK_TRANSACTIONS.length); + }); + + it('each card has a heading and a signed amount', () => { + setViewport('mobile'); + renderBH(); + const [credit, debit] = getCards(); + expect(within(credit).getByRole('heading', { name: MOCK_TRANSACTIONS[0].description })).toBeTruthy(); + expect(within(credit).getByText(/^\+/)).toBeTruthy(); + expect(within(debit).getByText(/^\u2212/)).toBeTruthy(); + }); +}); + +// ── Details sheet (issue #1320) ─────────────────────────────────────────────── + +const sheetTx = MOCK_TRANSACTIONS[0]; + +/** Render the mobile layout, focus and tap the first card's Details button. */ +function openSheet() { + setViewport('mobile'); + renderBH(); + const card = screen.getByTestId(`bh-card-${sheetTx.id}`); + const trigger = within(card).getByRole('button', { name: /^details/i }); + trigger.focus(); + fireEvent.click(trigger); + return { trigger, dialog: screen.queryByRole('dialog') as HTMLElement }; +} + +describe('BillingHistory — details sheet', () => { + afterEach(() => { + cleanup(); + _reset(); + window.matchMedia = originalMatchMedia; + }); + + it('Details opens a dialog with hash, network and confirmations', () => { + const { dialog } = openSheet(); + expect(dialog).toBeTruthy(); + expect(within(dialog).getByText(sheetTx.txHash)).toBeTruthy(); + expect(within(dialog).getByText(sheetTx.network)).toBeTruthy(); + expect(within(dialog).getByText(String(sheetTx.confirmations))).toBeTruthy(); + }); + + it('Escape closes the sheet and returns focus to the Details button', async () => { + const { trigger, dialog } = openSheet(); + expect(dialog).toBeTruthy(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).toBeNull(); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); +}); + +describe('BillingHistory — details sheet actions', () => { + afterEach(() => { + cleanup(); + _reset(); + window.matchMedia = originalMatchMedia; + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }); + }); + + it('the close button closes the sheet', () => { + const { dialog } = openSheet(); + expect(dialog).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: /^close/i })); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('Copy puts the full hash on the clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + const { dialog } = openSheet(); + expect(dialog).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: /copy/i })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(sheetTx.txHash)); + }); +}); + +// ── Mobile sort & filters (issue #1320) ─────────────────────────────────────── + +function LocationProbe() { + const location = useLocation(); + return {location.search}; +} + +const sortSelect = () => + screen.queryByRole('combobox', { name: /sort/i }) as HTMLSelectElement | null; +const cardIds = () => + within(screen.getByRole('list', { name: /billing transactions/i })) + .getAllByRole('listitem') + .map((li) => li.getAttribute('data-testid')); + +describe('BillingHistory — mobile sort select', () => { + afterEach(() => { cleanup(); _reset(); window.matchMedia = originalMatchMedia; }); + + it('mobile: shows one sort select', () => { + setViewport('mobile'); + renderBH(); + expect(sortSelect()).toBeTruthy(); + }); + + it('desktop: has no sort select (the headers sort instead)', () => { + setViewport('desktop'); + renderBH(); + expect(sortSelect()).toBeNull(); + }); +}); + +describe('BillingHistory — mobile sort behaviour', () => { + afterEach(() => { cleanup(); _reset(); window.matchMedia = originalMatchMedia; }); + + it('choosing an option reorders the cards and writes sort/dir to the URL', () => { + setViewport('mobile'); + render( + + + + , + ); + fireEvent.change(sortSelect() as HTMLSelectElement, { target: { value: 'amount:desc' } }); + const expected = [...MOCK_TRANSACTIONS] + .sort((a, b) => b.amount - a.amount) + .map((t) => `bh-card-${t.id}`); + expect(cardIds()).toEqual(expected); + const params = new URLSearchParams(screen.getByTestId('loc').textContent ?? ''); + expect(params.get('sort')).toBe('amount'); + expect(params.get('dir')).toBe('desc'); + }); + + it('the select reflects a pre-seeded URL sort', () => { + setViewport('mobile'); + renderBH(['/?sort=date&dir=asc']); + expect((sortSelect() as HTMLSelectElement).value).toBe('date:asc'); + }); +}); + +describe('BillingHistory — filters on mobile', () => { + afterEach(() => { cleanup(); _reset(); window.matchMedia = originalMatchMedia; }); + + function filteredCount(mode: 'mobile' | 'desktop') { + setViewport(mode); + const view = renderBH(); + fireEvent.change(screen.getByRole('combobox', { name: /filter by transaction type/i }), { + target: { value: 'Deposit' }, + }); + const count = + mode === 'mobile' + ? cardIds().length + : within(screen.getByRole('table')).getAllByRole('row').length - 1; + view.unmount(); + return count; + } + + it('the same filter shows the same number of transactions as on desktop', () => { + const mobile = filteredCount('mobile'); + const desktop = filteredCount('desktop'); + expect(mobile).toBe(desktop); + expect(mobile).toBe(MOCK_TRANSACTIONS.filter((t) => t.type === 'Deposit').length); + }); +}); + +describe('BillingHistory — touch-friendly wording', () => { + afterEach(() => { cleanup(); _reset(); window.matchMedia = originalMatchMedia; }); + + it('mobile: tells users to tap Details instead of hovering', () => { + setViewport('mobile'); + renderBH(); + expect(screen.getByText(/tap details/i)).toBeTruthy(); + expect(screen.queryByText(/hover/i)).toBeNull(); + }); + + it('mobile: hides the keyboard tip', () => { + setViewport('mobile'); + renderBH(); + expect(screen.queryByText(/keyboard tip/i)).toBeNull(); + }); + + it('desktop: keeps the hover wording and the keyboard tip', () => { + setViewport('desktop'); + renderBH(); + expect(screen.getByText(/hover or focus any transaction row/i)).toBeTruthy(); + expect(screen.getByText(/keyboard tip/i)).toBeTruthy(); + }); +}); diff --git a/src/pages/BillingHistory.tsx b/src/pages/BillingHistory.tsx index 44398ffb..48a6471b 100644 --- a/src/pages/BillingHistory.tsx +++ b/src/pages/BillingHistory.tsx @@ -22,12 +22,18 @@ * Blur / Tab away Closes the preview when focus leaves the row. */ -import { useId, useMemo, useState } from 'react'; +import { useId, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import PreviewCard, { type PreviewCardData } from '../components/PreviewCard'; import StatusBadge, { type StatusVariant } from '../components/StatusBadge'; import { formatUsdcAmount, formatDateShort, resolveTimeZone } from '../utils/format'; import { useAccountContext } from '../hooks/useAccountContext'; +import './BillingHistory.css'; +import TransactionCard from '../components/TransactionCard'; +import { useMediaQuery } from '../hooks/useMediaQuery'; +import BottomSheet from '../components/BottomSheet'; +import { TransactionDetails } from '../components/TransactionDetails'; +import BillingSortSelect from '../components/BillingSortSelect'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -169,6 +175,9 @@ const TX_STATUS_LABELS: Record = { const ALL_TYPES: TxType[] = ['Deposit', 'API Call', 'Refund', 'Settlement', 'Fee']; const ALL_STATUSES: TxStatus[] = ['success', 'pending', 'error', 'warning']; +/** Below this width transactions render as cards instead of a table. */ +const MOBILE_QUERY = '(max-width: 639px)'; + // Valid URL parameter values for sort column and direction. export type SortColumn = 'date' | 'amount'; export type SortDirection = 'asc' | 'desc'; @@ -214,6 +223,13 @@ function txToPreviewData(tx: BillingTransaction): PreviewCardData { export function BillingHistory() { const { timezone } = useAccountContext(); + const isMobile = useMediaQuery(MOBILE_QUERY); + const [selectedTx, setSelectedTx] = useState(null); + const detailsTriggerRef = useRef(null); + const openDetails = (tx: BillingTransaction, trigger: HTMLButtonElement) => { + detailsTriggerRef.current = trigger; + setSelectedTx(tx); + }; const dateZone = resolveTimeZone(timezone); const zoneAbbreviation = new Intl.DateTimeFormat(undefined, { timeZone: dateZone, @@ -231,23 +247,24 @@ export function BillingHistory() { const [searchParams, setSearchParams] = useSearchParams(); const { column: sortColumn, direction: sortDirection } = parseSortParams(searchParams); - /** Toggle sort: same column flips direction; new column starts asc. */ - function handleSort(col: SortColumn) { + /** Write an explicit sort to the URL (shared by the column headers and the mobile select). */ + function applySort(col: SortColumn, dir: SortDirection) { setSearchParams( (prev) => { const next = new URLSearchParams(prev); - if (sortColumn === col) { - next.set('dir', sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - next.set('sort', col); - next.set('dir', 'asc'); - } + next.set('sort', col); + next.set('dir', dir); return next; }, { replace: true }, ); } + /** Toggle sort: same column flips direction; new column starts asc. */ + function handleSort(col: SortColumn) { + applySort(col, sortColumn === col && sortDirection === 'asc' ? 'desc' : 'asc'); + } + /** aria-sort value for a given column header. */ function ariaSortFor(col: SortColumn): React.AriaAttributes['aria-sort'] { if (sortColumn !== col) return 'none'; @@ -308,45 +325,23 @@ export function BillingHistory() { // ── Render ──────────────────────────────────────────────────────────────── return ( -
    +
    {/* Page heading */} -
    -

    - Billing -

    -

    +
    +

    Billing

    +

    Billing History

    -

    - Hover or focus any transaction row to preview on-chain details. Press{' '} - - Esc - {' '} - to dismiss the preview. +

    + {isMobile ? ( + 'Tap Details on any transaction to see its on-chain details.' + ) : ( + <> + Hover or focus any transaction row to preview on-chain details. Press{' '} + Esc{' '} + to dismiss the preview. + + )}

    @@ -356,74 +351,34 @@ export function BillingHistory() { role="status" aria-live="polite" aria-atomic="true" - style={{ - position: 'absolute', - width: '1px', - height: '1px', - overflow: 'hidden', - clip: 'rect(0 0 0 0)', - whiteSpace: 'nowrap', - }} + className="bh-visually-hidden" > {filterSummary} {/* ── Filters bar ─────────────────────────────────────────────────── */} -
    +
    {/* Search */} -
    {/* ── Summary strip ────────────────────────────────────────────────── */} -
    +
    {filteredTxs.length} transaction{filteredTxs.length !== 1 ? 's' : ''} Net:{' '} 0 - ? 'var(--success, #10b981)' - : netBalance < 0 - ? 'var(--danger, #ef4444)' - : 'var(--text-primary, #f9fafb)', - }} + className={`tabular-nums ${ + netBalance > 0 + ? 'bh-net--positive' + : netBalance < 0 + ? 'bh-net--negative' + : 'bh-net--zero' + }`} > {netBalance >= 0 ? '+' : '−'} {formatUsdcAmount(Math.abs(netBalance))} USDC @@ -521,54 +446,31 @@ export function BillingHistory() { {/* ── Transaction table ─────────────────────────────────────────────── */} {filteredTxs.length === 0 ? ( -
    +
    No transactions match the current filters.
    + ) : isMobile ? ( +
      + {filteredTxs.map((tx) => ( + + ))} +
    ) : ( -
    - +
    - + {/* Date — sortable */} {/* Type — not sortable */} {/* Status — not sortable */} {/* Amount — sortable */} - {filteredTxs.map((tx, idx) => ( + {filteredTxs.map((tx) => ( {/* Date cell */} - {/* Description cell — wraps the PreviewCard trigger */} - {/* Type cell */} - {/* Status cell */} - {/* Amount cell */} - {/* Tx hash cell */} - @@ -818,28 +590,22 @@ export function BillingHistory() { )} {/* ── Accessible keyboard tip ──────────────────────────────────────── */} -

    + Keyboard tip: Tab to a transaction description to open its preview. + Press{' '} + Esc{' '} + to close it and return focus to that row. +

    + )} + setSelectedTx(null)} + title={selectedTx ? `${selectedTx.description} details` : 'Transaction details'} + triggerRef={detailsTriggerRef} > - Keyboard tip: Tab to a transaction description to open its preview. - Press{' '} - - Esc - {' '} - to close it and return focus to that row. -

    + {selectedTx && } +
    ); }
    + aria-sort={ariaSortFor('date')} className="bh-th bh-th--date"> @@ -601,62 +489,26 @@ export function BillingHistory() { {/* Description — not sortable */} + scope="col" className="bh-th bh-th--description"> Description + scope="col" className="bh-th bh-th--narrow"> Type + scope="col" className="bh-th bh-th--narrow"> Status + aria-sort={ariaSortFor('amount')} className="bh-th bh-th--amount"> @@ -689,71 +527,32 @@ export function BillingHistory() { {/* Tx Hash — not sortable */} + scope="col" className="bh-th bh-th--narrow"> Tx Hash
    + + - + {/* Direction indicator */} - + {tx.type} + - + + {tx.direction === 'credit' ? '+' : '−'} {formatUsdcAmount(tx.amount)} + {truncateTxHash(tx.txHash)}