From f805e4c63fd5ed546c3f32c59ce7c28e08dccea8 Mon Sep 17 00:00:00 2001 From: obafemimathew01-hue Date: Wed, 29 Jul 2026 00:14:24 +0000 Subject: [PATCH] feat(fwc26): hover/focus PreviewCard on BillingHistory Closes #FWC26 - Extend PreviewCardData with billing fields: txHash, network, confirmations, type, amount, direction, timestamp - Auto-detect billing mode; existing dashboard callers unaffected - Add BillingHistory page at /billing/history with filterable transaction table; each row triggers PreviewCard on hover/focus - Keyboard accessible: Tab to focus opens preview, Escape closes it and restores focus to the trigger (WCAG 2.1 AA) - aria-live polite region announces filter changes to screen readers - StatusBadge on every row (pattern + colour, WCAG 1.4.1) - Register route in App.tsx with NavLink and page title/description - 56 focused tests (25 PreviewCard + 31 BillingHistory), all passing - Add docs/BillingHistoryPreview.md with API reference and a11y notes --- docs/BillingHistoryPreview.md | 215 +++++++++ src/App.tsx | 10 + src/components/PreviewCard.test.tsx | 377 ++++++++++++++-- src/components/PreviewCard.tsx | 370 +++++++++++++-- src/pages/BillingHistory.test.tsx | 347 +++++++++++++++ src/pages/BillingHistory.tsx | 669 ++++++++++++++++++++++++++++ 6 files changed, 1907 insertions(+), 81 deletions(-) create mode 100644 docs/BillingHistoryPreview.md create mode 100644 src/pages/BillingHistory.test.tsx create mode 100644 src/pages/BillingHistory.tsx diff --git a/docs/BillingHistoryPreview.md b/docs/BillingHistoryPreview.md new file mode 100644 index 0000000..5eb9df6 --- /dev/null +++ b/docs/BillingHistoryPreview.md @@ -0,0 +1,215 @@ +# BillingHistory Preview Card + +**Campaign:** GrantFox FWC26 +**Issue:** UI/UX — hover-triggered preview card on BillingHistory; keyboard-accessible alternative +**Route:** `/billing/history` +**Files changed:** +- `src/components/PreviewCard.tsx` — extended with billing-specific fields +- `src/pages/BillingHistory.tsx` — new page component +- `src/App.tsx` — route + navigation entries added + +--- + +## Overview + +The Billing History page displays a paginated table of USDC transactions. Each row's description cell is wrapped in a **PreviewCard** trigger. Hovering or focusing the trigger opens a compact floating panel that reveals on-chain details — transaction hash, network, confirmation count, direction badge, formatted amount, and timestamp — without requiring the user to navigate to a separate page. + +--- + +## User flows + +### Mouse user + +1. Visit `/billing/history`. +2. Hover the description text of any transaction row. +3. A tooltip-style card appears below the cell with: + - Transaction type + credit/debit badge + - Formatted USDC amount (e.g. `+100.00 USDC`) + - Network name (`Stellar Mainnet`) + - Confirmation count + - Truncated tx hash (full hash available in the `title` attribute) + - Formatted timestamp +4. Moving the cursor off the row closes the card. + +### Keyboard user + +1. Tab to a transaction row's description trigger. +2. The preview card opens automatically on focus. +3. Read the on-chain details via the `aria-describedby` association or by listening to the `role="tooltip"` announcement. +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). + +--- + +## Filtering + +The page exposes four filter controls above the table: + +| Control | Values | +|---------|--------| +| Search | Free text (description or tx hash prefix) | +| Type | All / Deposit / API Call / Refund / Settlement / Fee | +| Status | All / success / pending / error / warning | +| Direction | All / Credit / Debit | + +Filters are combinable. A live region (`role="status" aria-live="polite"`) announces the active filter state to screen readers after each change. + +When no transactions match, a clearly labelled empty-state message replaces the table. + +--- + +## API — PreviewCard + +`PreviewCard` is a shared component (`src/components/PreviewCard.tsx`). It accepts a `PreviewCardData` object, a trigger as `children`, an optional `position` prop, and an optional `className`. + +### `PreviewCardData` — complete type + +```ts +export interface PreviewCardData { + id: string; + title: string; + + // — Shared (dashboard overview + billing history) + subtitle?: string; + category?: string; + status?: StatusVariant; // See StatusBadge + description?: string; + metrics?: PreviewMetric[]; // { label, value }[] + tags?: string[]; + price?: string | number; + lastActive?: string; + details?: Record; + + // — Billing-specific (GrantFox FWC26) + txHash?: string; // Full 64-char Stellar hash + network?: string; // e.g. "Stellar Mainnet" + confirmations?: number; // Ledger confirmation count + type?: string; // "Deposit" | "API Call" | etc. + timestamp?: string; // ISO 8601 + amount?: number; // USDC value (positive) + direction?: 'credit' | 'debit'; +} +``` + +**Mode detection:** if any of `txHash`, `network`, `confirmations`, `type`, or `amount` is present, the card renders the billing-specific section and suppresses the generic `metrics` grid and `price/lastActive` footer. Existing callers (DashboardOverview) are unaffected. + +### `PreviewCardProps` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `data` | `PreviewCardData` | required | Card content | +| `children` | `ReactNode` | required | Hover/focus trigger element | +| `position` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` | Floating panel placement | +| `className` | `string` | `''` | Extra class on the wrapper `div` | + +### Usage example — billing row + +```tsx +import PreviewCard, { type PreviewCardData } from '../components/PreviewCard'; + +const data: PreviewCardData = { + id: 'tx-001', + title: 'USDC vault deposit', + status: 'success', + type: 'Deposit', + direction: 'credit', + amount: 100.00, + txHash: 'A3F9B2C1D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0A1', + network: 'Stellar Mainnet', + confirmations: 120, + timestamp: '2026-07-25T14:32:00Z', +}; + + + USDC vault deposit + +``` + +--- + +## API — BillingHistory page + +`BillingHistory` is a default-exported React component at `src/pages/BillingHistory.tsx`. It requires no props — all data comes from the internal `MOCK_TRANSACTIONS` constant. + +### Exports + +| Name | Kind | Description | +|------|------|-------------| +| `BillingHistory` | named + default export | Page component | +| `MOCK_TRANSACTIONS` | named export | Array of `BillingTransaction` for use in tests and Storybook | +| `BillingTransaction` | type | Shape of a single transaction record | +| `TxType` | type | `'Deposit' \| 'API Call' \| 'Refund' \| 'Settlement' \| 'Fee'` | +| `TxDirection` | type | `'credit' \| 'debit'` | +| `TxStatus` | type | Subset of `StatusVariant` used for transactions | + +--- + +## Accessibility notes (WCAG 2.1 AA) + +| Criterion | Implementation | +|-----------|----------------| +| **1.1.1 Non-text content** | Direction arrows are `aria-hidden`; information is also in the `direction` badge text. | +| **1.3.1 Info and Relationships** | `` with `` / `` exposes structure. | +| **1.3.3 Sensory Characteristics** | Status is never conveyed by colour alone — `StatusBadge` adds a texture pattern. | +| **1.4.1 Use of Color** | Credit/debit is labelled with text ("↑ credit" / "↓ debit") in addition to colour. | +| **1.4.3 Contrast** | All colours reference design tokens; both dark and light themes pass 4.5:1 minimum. | +| **2.1.1 Keyboard** | All PreviewCard triggers are `tabIndex={0}` with `role="button"`. Escape dismisses the preview. | +| **2.4.3 Focus Order** | Escape restores focus to the trigger; `suppressNextFocus` guard prevents immediate re-open. | +| **2.4.6 Headings and Labels** | Page heading (`

`), filter group (`role="group" aria-label`), and table all have labels. | +| **4.1.2 Name, Role, Value** | Trigger: `role="button"`, `aria-label`, `aria-describedby`. Panel: `role="tooltip"`, `aria-label`. | +| **4.1.3 Status Messages** | Filter changes announced via `role="status" aria-live="polite" aria-atomic="true"`. | + +### Skip-link support + +The page lives under `
` which the app-level skip link already targets — no additional work needed. + +--- + +## Design-token usage + +All colours in `PreviewCard` and `BillingHistory` use CSS custom properties so both light and dark themes are covered automatically. + +| Token | Used for | +|-------|----------| +| `--surface` | Preview card background | +| `--line` / `--border-color` | Preview card and table borders | +| `--shadow` | Preview card drop shadow | +| `--text-primary` / `--text` | Primary text | +| `--text-secondary` / `--muted` | Secondary / muted text | +| `--accent` | Tx hash, confirmation count, accent values | +| `--success` | Credit amounts, high-confirmation count | +| `--danger` | Debit amounts, error states | +| `--bg-chip` | Billing details section background | +| `--sb-success-bg/fg` | Credit direction badge | +| `--sb-error-bg/fg` | Debit direction badge | + +--- + +## 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. +- The page heading uses `clamp(1.35rem, 3vw, 1.75rem)` for fluid sizing. + +--- + +## Running the tests + +```bash +# New tests only +npm test -- --run src/components/PreviewCard.test.tsx src/pages/BillingHistory.test.tsx + +# Full suite +npm test -- --run +``` + +Expected result: **56 new tests pass** (31 BillingHistory + 25 PreviewCard). + +--- + +## Related docs + +- [UI Design System](./UI-Design-System.md) — design tokens and component guidelines +- [ResponseDiff](./ResponseDiff.md) — similar hover/focus pattern on `CallHistoryRow` +- `src/components/EndpointPreview.tsx` — the original hover preview pattern this feature follows diff --git a/src/App.tsx b/src/App.tsx index 4a3f487..cc9cc9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import RateLimitCard from "./pages/RateLimitCard"; import { ShortcutsModal } from "./components/ShortcutsModal"; import { ToastProvider } from "./components/Toast"; import { InvoiceCard } from "./pages/InvoiceCard"; +import BillingHistory from "./pages/BillingHistory"; type DepositStage = "input" | "approving" | "pending" | "confirmed" | "failed"; type DemoOutcome = "confirmed" | "failed"; @@ -117,6 +118,7 @@ const APP_ROUTES = { planBadge: "/apis/plan-badge", apiUsage: "/api-usage", billing: "/billing", + billingHistory: "/billing/history", documentation: "/documentation", status: "/status", themePlayground: "/theme-playground", @@ -277,6 +279,7 @@ function App() { [APP_ROUTES.dashboard]: "Dashboard – Callora", [APP_ROUTES.myApis]: "My APIs – Callora", [APP_ROUTES.billing]: "Billing – Callora", + [APP_ROUTES.billingHistory]: "Billing History – Callora", "/api-usage": "API Usage – Callora", [APP_ROUTES.landing]: "Callora", [APP_ROUTES.endpointSummary]: "Endpoint Summary – Callora", @@ -285,6 +288,7 @@ function App() { [APP_ROUTES.marketplace]: "Explore APIs on the Callora marketplace, discover and integrate APIs for your applications.", [APP_ROUTES.dashboard]: "Your Callora dashboard showing balances, recent activity and quick actions.", [APP_ROUTES.billing]: "Manage your USDC vault, deposit funds, and view transaction status.", + [APP_ROUTES.billingHistory]: "View your full USDC transaction history with on-chain details, filters, and hover previews.", "/api-usage": "Monitor API usage, request stats, and view call history.", [APP_ROUTES.landing]: "Callora - Programmable API Access, pay-per-call billing, and on-chain settlement.", [APP_ROUTES.endpointSummary]: "Quick reference list of all API endpoints on Callora.", @@ -554,6 +558,9 @@ function App() { (isActive ? "link-nav active" : "link-nav")}> Billing + (isActive ? "link-nav active" : "link-nav")}> + Billing History + (isActive ? "link-nav active" : "link-nav")}> Theme Playground @@ -685,6 +692,9 @@ function App() { } /> + {/* ── Billing History (FWC26) ──────────────────────────────── */} + } /> + navigate(APP_ROUTES.dashboard)} />} />
diff --git a/src/components/PreviewCard.test.tsx b/src/components/PreviewCard.test.tsx index 659e085..1554adb 100644 --- a/src/components/PreviewCard.test.tsx +++ b/src/components/PreviewCard.test.tsx @@ -1,10 +1,22 @@ // @vitest-environment jsdom +/** + * PreviewCard.test.tsx + * + * Tests for the PreviewCard component covering: + * - Dashboard/generic usage (original issue #689 surface) + * - Billing-specific mode (GrantFox FWC26 — txHash, network, + * confirmations, type, amount, direction, timestamp fields) + * - Keyboard accessibility (focus open, Escape close, aria-describedby) + * - ARIA semantics (role="tooltip", aria-label) + */ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import PreviewCard, { type PreviewCardData } from './PreviewCard'; -const MOCK_PREVIEW_DATA: PreviewCardData = { +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const DASHBOARD_DATA: PreviewCardData = { id: 'test-api-preview', title: 'WeatherSim API', subtitle: 'Callora Verified', @@ -20,44 +32,65 @@ const MOCK_PREVIEW_DATA: PreviewCardData = { lastActive: '10 mins ago', }; -describe('PreviewCard Component', () => { +/** Billing-mode data: includes at least one FWC26-specific field. */ +const BILLING_DATA: PreviewCardData = { + id: 'tx-001', + title: 'USDC vault deposit', + status: 'success', + type: 'Deposit', + direction: 'credit', + amount: 100.0, + txHash: 'A3F9B2C1D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6C7D8E9F0A1', + network: 'Stellar Mainnet', + confirmations: 120, + timestamp: '2026-07-25T14:32:00Z', +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getWrapper(container: HTMLElement) { + return container.querySelector('.preview-card__wrapper') as HTMLElement; +} + +function getTrigger() { + return screen.getByRole('button', { name: /preview details for/i }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('PreviewCard — shared behaviour', () => { afterEach(cleanup); it('does not render the preview panel initially', () => { render( - -
Card trigger
+ +
trigger
, ); expect(screen.queryByRole('tooltip')).toBeNull(); }); - it('renders trigger element with an accessible aria-label', () => { + it('renders a trigger with an accessible aria-label', () => { render( - -
Card trigger
+ +
trigger
, ); - - const trigger = screen.getByRole('button', { - name: /preview details for weathersim api/i, - }); - expect(trigger).toBeTruthy(); + expect( + screen.getByRole('button', { name: /preview details for weathersim api/i }), + ).toBeTruthy(); }); - it('opens panel on mouseEnter and hides on mouseLeave', () => { + it('opens panel on mouseEnter and closes on mouseLeave', () => { const { container } = render( - -
Card trigger
+ +
trigger
, ); - - const wrapper = container.querySelector('.preview-card__wrapper') as HTMLElement; - expect(wrapper).toBeTruthy(); + const wrapper = getWrapper(container); fireEvent.mouseEnter(wrapper); expect(screen.getByRole('tooltip')).toBeTruthy(); - expect(screen.getByRole('tooltip')).toHaveTextContent('WeatherSim API'); fireEvent.mouseLeave(wrapper); expect(screen.queryByRole('tooltip')).toBeNull(); @@ -65,47 +98,50 @@ describe('PreviewCard Component', () => { it('opens panel when trigger receives keyboard focus', () => { render( - -
Card trigger
+ +
trigger
, ); - - const trigger = screen.getByRole('button', { - name: /preview details for weathersim api/i, - }); - - fireEvent.focus(trigger); + fireEvent.focus(getTrigger()); expect(screen.getByRole('tooltip')).toBeTruthy(); }); - it('links trigger to preview panel via aria-describedby while open', () => { + it('sets aria-describedby on trigger while panel is open', () => { render( - -
Card trigger
+ +
trigger
, ); - - const trigger = screen.getByRole('button', { - name: /preview details for weathersim api/i, - }); - + const trigger = getTrigger(); fireEvent.focus(trigger); const panel = screen.getByRole('tooltip'); expect(trigger.getAttribute('aria-describedby')).toBe(panel.id); }); - it('closes preview panel and clears aria-describedby on Escape key', () => { - render( - -
Card trigger
+ it('clears aria-describedby when panel is closed', () => { + const { container } = render( + +
trigger
, ); + const wrapper = getWrapper(container); + const trigger = getTrigger(); + + fireEvent.mouseEnter(wrapper); + expect(trigger.getAttribute('aria-describedby')).toBeTruthy(); - const trigger = screen.getByRole('button', { - name: /preview details for weathersim api/i, - }); + fireEvent.mouseLeave(wrapper); + expect(trigger.getAttribute('aria-describedby')).toBeNull(); + }); + it('closes panel on Escape and removes aria-describedby', () => { + render( + +
trigger
+
, + ); + const trigger = getTrigger(); fireEvent.focus(trigger); expect(screen.getByRole('tooltip')).toBeTruthy(); @@ -114,17 +150,64 @@ describe('PreviewCard Component', () => { expect(trigger.getAttribute('aria-describedby')).toBeNull(); }); - it('renders title, status, description, metrics, and tags inside panel', () => { + it('panel has role="tooltip" and an aria-label', () => { render( - -
Card trigger
+ +
trigger
, ); + fireEvent.focus(getTrigger()); - const trigger = screen.getByRole('button', { - name: /preview details for weathersim api/i, - }); - fireEvent.focus(trigger); + const panel = screen.getByRole('tooltip'); + expect(panel.getAttribute('aria-label')).toBeTruthy(); + }); + + it('panel has pointer-events: none to avoid mouse trapping', () => { + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); + + const panel = screen.getByRole('tooltip'); + expect(panel.style.pointerEvents).toBe('none'); + }); + + it('passes custom position prop without crashing', () => { + const { container } = render( + +
trigger
+
, + ); + const wrapper = getWrapper(container); + fireEvent.mouseEnter(wrapper); + expect(screen.getByRole('tooltip')).toBeTruthy(); + }); + + it('passes className to wrapper', () => { + const { container } = render( + +
trigger
+
, + ); + const wrapper = getWrapper(container); + expect(wrapper.classList.contains('custom-class')).toBe(true); + }); +}); + +// ── Dashboard-mode tests ────────────────────────────────────────────────────── + +describe('PreviewCard — dashboard mode (no billing fields)', () => { + afterEach(cleanup); + + it('renders title, status badge, description, metrics, and tags', () => { + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); const panel = screen.getByRole('tooltip'); expect(panel).toHaveTextContent('WeatherSim API'); @@ -133,5 +216,201 @@ describe('PreviewCard Component', () => { expect(panel).toHaveTextContent('35ms'); expect(panel).toHaveTextContent('#weather'); expect(panel).toHaveTextContent('$0.005 / call'); + expect(panel).toHaveTextContent('Last active: 10 mins ago'); + }); + + it('does not render the billing section when no billing fields are provided', () => { + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); + + // The billing section shows "Network", "Confirmations", "Tx hash" + const panel = screen.getByRole('tooltip'); + expect(panel).not.toHaveTextContent('Network'); + expect(panel).not.toHaveTextContent('Confirmations'); + expect(panel).not.toHaveTextContent('Tx hash'); + }); +}); + +// ── Billing-mode tests (FWC26) ──────────────────────────────────────────────── + +describe('PreviewCard — billing mode (FWC26)', () => { + afterEach(cleanup); + + it('renders billing section when txHash is provided', () => { + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); + + const panel = screen.getByRole('tooltip'); + expect(panel).toHaveTextContent('Tx hash'); + // truncated hash: first 8 chars + expect(panel).toHaveTextContent('A3F9B2C1'); + }); + + it('renders transaction type and credit direction badge', () => { + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); + + const panel = screen.getByRole('tooltip'); + expect(panel).toHaveTextContent('Type'); + expect(panel).toHaveTextContent('Deposit'); + expect(panel).toHaveTextContent('credit'); + }); + + it('renders debit direction badge when direction is debit', () => { + const debitData: PreviewCardData = { + ...BILLING_DATA, + direction: 'debit', + title: 'API Call', + }; + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); + + expect(screen.getByRole('tooltip')).toHaveTextContent('debit'); + }); + + it('renders formatted amount with USDC label', () => { + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); + + const panel = screen.getByRole('tooltip'); + expect(panel).toHaveTextContent('Amount'); + expect(panel).toHaveTextContent('100.00 USDC'); + }); + + it('renders network name', () => { + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); + + expect(screen.getByRole('tooltip')).toHaveTextContent('Stellar Mainnet'); + }); + + it('renders confirmation count', () => { + render( + +
trigger
+
, + ); + fireEvent.focus(getTrigger()); + + const panel = screen.getByRole('tooltip'); + expect(panel).toHaveTextContent('Confirmations'); + expect(panel).toHaveTextContent('120'); + }); + + it('renders timestamp as a

` / `