diff --git a/src/index.css b/src/index.css index 88a2c45..5b642b6 100644 --- a/src/index.css +++ b/src/index.css @@ -5719,6 +5719,125 @@ code, } } +/* ─── BillingHistory ───────────────────────────────────────────────────────── + Hover-triggered / keyboard-accessible billing history rows. + GrantFox FWC26 campaign (Issue #692) — matches DashboardOverview pattern. + ────────────────────────────────────────────────────────────────────────── */ + +.billing-history { + max-width: 960px; + margin: 0 auto; +} + +.billing-history__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.billing-history__title { + margin: 0; + font-size: clamp(1.25rem, 2.5vw, 1.5rem); + color: var(--text); +} + +.billing-history__subtitle { + margin: 4px 0 0; + font-size: 0.85rem; + color: var(--muted); +} + +.billing-history__list { + display: grid; + gap: 8px; + list-style: none; + padding: 0; + margin: 0; +} + +.billing-history__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-radius: var(--radius-lg, 12px); + background: var(--surface-soft); + border: 1px solid var(--line); + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.billing-history__item:hover { + border-color: var(--accent, #6366f1); + box-shadow: var(--focus-ring); +} + +.billing-history__item-left { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.billing-history__item-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); + line-height: 1.3; +} + +.billing-history__item-meta { + font-size: 0.78rem; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.billing-history__item-right { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.billing-history__item-amount { + font-weight: 700; + font-variant-numeric: tabular-nums; + font-size: 0.9rem; + color: var(--text); +} + +.billing-history__item-amount--credit { + color: var(--success, #10b981); +} + +.billing-history__item-amount--debit { + color: var(--text); +} + +.billing-history__empty { + text-align: center; + padding: 48px 24px; + color: var(--muted); + font-size: 0.9rem; +} + +@media (max-width: 640px) { + .billing-history__item { + flex-direction: column; + align-items: flex-start; + } + + .billing-history__item-right { + width: 100%; + justify-content: space-between; + } +} + /* ─── ApiDetailStickyTOC ──────────────────────────────────────────────────── Sticky right-rail TOC inside the documentation tab. Tokens only — no hardcoded hex values. Transition respects prefers-reduced-motion. diff --git a/src/pages/BillingHistory.test.tsx b/src/pages/BillingHistory.test.tsx new file mode 100644 index 0000000..4fccd90 --- /dev/null +++ b/src/pages/BillingHistory.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import BillingHistory, { type BillingRecord } from './BillingHistory'; + +const MOCK_RECORDS: BillingRecord[] = [ + { + id: 'bill-1', + type: 'invoice', + title: 'Monthly API Usage', + description: 'Aggregated charges for all API calls in February 2026.', + amount: 4200.0, + date: '2026-02-28', + status: 'success', + invoiceNumber: 'INV-2026-0028', + category: 'API Usage', + method: 'Vault', + }, + { + id: 'bill-2', + type: 'payment', + title: 'USDC Vault Deposit', + description: 'Deposit from connected Freighter wallet.', + amount: 500.0, + date: '2026-02-15', + status: 'operational', + method: 'Freighter Wallet', + }, + { + id: 'bill-3', + type: 'charge', + title: 'WeatherSim API Calls', + description: 'Per-request charges for WeatherSim /v1/forecast endpoint.', + amount: 12.5, + date: '2026-02-27', + status: 'operational', + category: 'Weather & Climate', + }, +]; + +describe('BillingHistory Page Component (#692)', () => { + afterEach(cleanup); + + it('renders the section heading and subtitle', () => { + render(); + + expect(screen.getByText('Billing History')).toBeTruthy(); + expect( + screen.getByText('Invoices, payments, and usage charges with preview details on hover or focus.'), + ).toBeTruthy(); + }); + + it('renders all billing records as list items', () => { + render(); + + expect(screen.getByText('Monthly API Usage')).toBeTruthy(); + expect(screen.getByText('USDC Vault Deposit')).toBeTruthy(); + expect(screen.getByText('WeatherSim API Calls')).toBeTruthy(); + }); + + it('renders invoice numbers and dates for records that have them', () => { + render(); + + expect(screen.getByText('INV-2026-0028')).toBeTruthy(); + expect(screen.getByText('2026-02-28')).toBeTruthy(); + }); + + it('renders formatted amounts with correct sign (credit vs debit)', () => { + render(); + + // Invoice (debit): -USDC 4,200.00 + expect(screen.getByText(/-USDC 4,200\.00/)).toBeTruthy(); + // Payment (credit): +USDC 500.00 + expect(screen.getByText(/\+USDC 500\.00/)).toBeTruthy(); + }); + + it('shows empty state when records array is empty', () => { + render(); + + expect(screen.getByText('No billing records yet.')).toBeTruthy(); + }); + + it('wraps each billing item in a PreviewCard trigger', () => { + render(); + + const trigger1 = screen.getByRole('button', { + name: /preview details for monthly api usage/i, + }); + expect(trigger1).toBeTruthy(); + + const trigger2 = screen.getByRole('button', { + name: /preview details for usdc vault deposit/i, + }); + expect(trigger2).toBeTruthy(); + }); + + it('opens preview panel on focus and shows record details', () => { + render(); + + const trigger = screen.getByRole('button', { + name: /preview details for monthly api usage/i, + }); + + fireEvent.focus(trigger); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toBeTruthy(); + expect(tooltip).toHaveTextContent('Monthly API Usage'); + expect(tooltip).toHaveTextContent('Invoice'); + expect(tooltip).toHaveTextContent('Aggregated charges for all API calls in February 2026.'); + }); + + it('opens preview panel on hover and shows correct metrics', () => { + const { container } = render(); + + const wrapper = container.querySelector('.preview-card__wrapper') as HTMLElement; + expect(wrapper).toBeTruthy(); + + fireEvent.mouseEnter(wrapper); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toBeTruthy(); + expect(tooltip).toHaveTextContent('Amount'); + expect(tooltip).toHaveTextContent('Date'); + }); + + it('closes preview panel on Escape key', () => { + render(); + + const trigger = screen.getByRole('button', { + name: /preview details for monthly api usage/i, + }); + + fireEvent.focus(trigger); + expect(screen.getByRole('tooltip')).toBeTruthy(); + + fireEvent.keyDown(trigger, { key: 'Escape' }); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + it('links trigger to preview panel via aria-describedby while open', () => { + render(); + + const trigger = screen.getByRole('button', { + name: /preview details for monthly api usage/i, + }); + + fireEvent.focus(trigger); + + const panel = screen.getByRole('tooltip'); + expect(trigger.getAttribute('aria-describedby')).toBe(panel.id); + }); + + it('renders refund type as credit with positive sign', () => { + const refundRecord: BillingRecord = { + id: 'bill-refund', + type: 'refund', + title: 'API Overcharge Refund', + description: 'Refund issued for duplicate charge on WeatherSim API.', + amount: 25.0, + date: '2026-03-01', + status: 'success', + invoiceNumber: 'RFND-001', + }; + + render(); + + expect(screen.getByText(/\+USDC 25\.00/)).toBeTruthy(); + }); +}); diff --git a/src/pages/BillingHistory.tsx b/src/pages/BillingHistory.tsx new file mode 100644 index 0000000..ea33fd6 --- /dev/null +++ b/src/pages/BillingHistory.tsx @@ -0,0 +1,126 @@ +import { useId } from 'react'; +import PreviewCard, { type PreviewCardData } from '../components/PreviewCard'; + +export type BillingRecordType = 'invoice' | 'payment' | 'charge' | 'refund'; + +export interface BillingRecord { + id: string; + type: BillingRecordType; + title: string; + description?: string; + amount: number; + currency?: string; + date: string; + status?: 'operational' | 'success' | 'pending' | 'error'; + invoiceNumber?: string; + category?: string; + method?: string; +} + +export interface BillingHistoryProps { + records: BillingRecord[]; + className?: string; +} + +const TYPE_LABELS: Record = { + invoice: 'Invoice', + payment: 'Payment', + charge: 'Usage Charge', + refund: 'Refund', +}; + +const AMOUNT_SIGNS: Record = { + invoice: 'debit', + payment: 'credit', + charge: 'debit', + refund: 'credit', +}; + +function formatCurrency(amount: number, currency = 'USDC'): string { + return `${currency} ${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function toPreviewCardData(record: BillingRecord): PreviewCardData { + const sign = AMOUNT_SIGNS[record.type]; + const isCredit = sign === 'credit'; + + return { + id: `billing-preview-${record.id}`, + title: record.title, + subtitle: `${TYPE_LABELS[record.type]}${record.invoiceNumber ? ` · ${record.invoiceNumber}` : ''}`, + category: record.category, + description: record.description, + status: record.status ?? 'operational', + metrics: [ + { label: 'Amount', value: `${isCredit ? '+' : '-'}${formatCurrency(record.amount, record.currency)}` }, + { label: 'Date', value: record.date }, + ], + price: isCredit ? undefined : Math.abs(record.amount), + lastActive: record.date, + }; +} + +export function BillingHistory({ records, className = '' }: BillingHistoryProps) { + const titleId = useId(); + + return ( +
+
+
+

+ Billing History +

+

+ Invoices, payments, and usage charges with preview details on hover or focus. +

+
+
+ + {records.length === 0 ? ( +
+ No billing records yet. +
+ ) : ( +
    + {records.map((record) => { + const previewData = toPreviewCardData(record); + const sign = AMOUNT_SIGNS[record.type]; + const amountClass = + sign === 'credit' ? 'billing-history__item-amount--credit' : 'billing-history__item-amount--debit'; + + return ( +
  • + +
    +
    + {record.title} + + {TYPE_LABELS[record.type]} + {record.invoiceNumber && {record.invoiceNumber}} + {record.date} + +
    +
    + + {sign === 'credit' ? '+' : '-'} + {formatCurrency(record.amount, record.currency)} + +
    +
    +
    +
  • + ); + })} +
+ )} +
+ ); +} + +export default BillingHistory;