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 (
+
- 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.
+ >
+ )}
@@ -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 && }
+
);
}