diff --git a/src/app/payment/dashboard/PaymentDashboardContent.tsx b/src/app/payment/dashboard/PaymentDashboardContent.tsx index 00dcf3b8..4031c891 100644 --- a/src/app/payment/dashboard/PaymentDashboardContent.tsx +++ b/src/app/payment/dashboard/PaymentDashboardContent.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { PaymentHistory } from '@/components/payment/PaymentHistory'; +import { PaymentQueuePanel } from '@/components/payment/PaymentQueuePanel'; import { featureFlags } from '@/config/payment'; /** @@ -43,6 +44,18 @@ export function PaymentDashboardContent() { )} + {/* Offline payment queue management (#4). Shown when a provider is + configured — the queue itself works offline regardless, but the panel + is only meaningful where payments can actually be made. */} + {!noProvidersConfigured && ( +
+

+ Offline payment queue +

+ +
+ )} +

Payment History diff --git a/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.accessibility.test.tsx b/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.accessibility.test.tsx new file mode 100644 index 00000000..01440850 --- /dev/null +++ b/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.accessibility.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import PaymentQueuePanel from './PaymentQueuePanel'; +import type { PaymentQueueItem } from '@/lib/offline-queue/types'; + +expect.extend(toHaveNoViolations); + +const items: PaymentQueueItem[] = [ + { + id: 1, + status: 'failed', + retries: 2, + createdAt: Date.now(), + type: 'payment_intent', + data: { amount: 1000 }, + lastError: 'Network error', + } as PaymentQueueItem, +]; + +let queueData: PaymentQueueItem[] = []; + +vi.mock('@/lib/offline-queue/payment-adapter', () => ({ + paymentQueue: { + getQueue: () => Promise.resolve(queueData), + retryFailed: () => Promise.resolve(0), + sync: () => + Promise.resolve({ success: 0, failed: 0, skipped: 0, conflicted: 0 }), + clear: () => Promise.resolve(), + }, +})); + +vi.mock('@/hooks/useOfflineStatus', () => ({ + useOfflineStatus: () => ({ isOffline: false }), +})); + +describe('PaymentQueuePanel Accessibility', () => { + beforeEach(() => { + queueData = []; + }); + + it('has no violations in the empty state', async () => { + const { container } = render(); + await waitFor(() => expect(container.firstChild).toBeInTheDocument()); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations with queued items', async () => { + queueData = items; + const { container, findByTestId } = render( + + ); + await findByTestId('queue-items'); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.stories.tsx b/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.stories.tsx new file mode 100644 index 00000000..f9e57bf5 --- /dev/null +++ b/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import PaymentQueuePanel from './PaymentQueuePanel'; + +const meta: Meta = { + title: 'Features/Payment/PaymentQueuePanel', + component: PaymentQueuePanel, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'User-facing management surface for the offline payment queue (#4) — status, queued items + retry counts, manual retry, and clear-queue. Reads the live `paymentQueue`; in Storybook (empty IndexedDB) it shows the empty state.', + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { pollIntervalMs: 999999 }, +}; diff --git a/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.test.tsx b/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.test.tsx new file mode 100644 index 00000000..5173c84a --- /dev/null +++ b/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.test.tsx @@ -0,0 +1,126 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import PaymentQueuePanel from './PaymentQueuePanel'; +import type { PaymentQueueItem } from '@/lib/offline-queue/types'; + +const mockQueue = { + getQueue: vi.fn(), + retryFailed: vi.fn(), + sync: vi.fn(), + clear: vi.fn(), +}; +let offline = false; + +vi.mock('@/lib/offline-queue/payment-adapter', () => ({ + paymentQueue: { + getQueue: (...a: unknown[]) => mockQueue.getQueue(...a), + retryFailed: (...a: unknown[]) => mockQueue.retryFailed(...a), + sync: (...a: unknown[]) => mockQueue.sync(...a), + clear: (...a: unknown[]) => mockQueue.clear(...a), + }, +})); + +vi.mock('@/hooks/useOfflineStatus', () => ({ + useOfflineStatus: () => ({ isOffline: offline }), +})); + +function makeItem(over: Partial = {}): PaymentQueueItem { + return { + id: 1, + status: 'pending', + retries: 0, + createdAt: Date.now(), + type: 'payment_intent', + data: { amount: 1000 }, + ...over, + } as PaymentQueueItem; +} + +describe('PaymentQueuePanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + offline = false; + mockQueue.getQueue.mockResolvedValue([]); + mockQueue.retryFailed.mockResolvedValue(0); + mockQueue.sync.mockResolvedValue({ + success: 0, + failed: 0, + skipped: 0, + conflicted: 0, + }); + mockQueue.clear.mockResolvedValue(undefined); + }); + + it('shows the empty state when the queue is empty', async () => { + render(); + await waitFor(() => + expect(screen.getByTestId('queue-count')).toHaveTextContent( + /no queued payments/i + ) + ); + expect(screen.getByTestId('queue-retry')).toBeDisabled(); + expect(screen.getByTestId('queue-clear')).toBeDisabled(); + }); + + it('lists queued items with their retry counts', async () => { + mockQueue.getQueue.mockResolvedValue([ + makeItem({ id: 1, retries: 2 }), + makeItem({ id: 2, status: 'failed', retries: 5, lastError: 'boom' }), + ]); + render(); + await waitFor(() => + expect(screen.getByTestId('queue-items')).toBeInTheDocument() + ); + expect(screen.getByTestId('queue-item-retries-1')).toHaveTextContent( + /attempt 2\/5/i + ); + // At max retries → "Max retries" + expect(screen.getByTestId('queue-item-retries-2')).toHaveTextContent( + /max retries/i + ); + expect(screen.getByText('boom')).toBeInTheDocument(); + expect(screen.getByTestId('queue-count')).toHaveTextContent(/1 pending/i); + expect(screen.getByTestId('queue-count')).toHaveTextContent(/1 failed/i); + }); + + it('retry calls retryFailed then sync', async () => { + mockQueue.getQueue.mockResolvedValue([ + makeItem({ status: 'failed', retries: 1 }), + ]); + render(); + await waitFor(() => + expect(screen.getByTestId('queue-retry')).toBeEnabled() + ); + fireEvent.click(screen.getByTestId('queue-retry')); + await waitFor(() => expect(mockQueue.retryFailed).toHaveBeenCalledTimes(1)); + expect(mockQueue.sync).toHaveBeenCalledTimes(1); + }); + + it('disables Retry now when offline', async () => { + offline = true; + mockQueue.getQueue.mockResolvedValue([makeItem()]); + render(); + await waitFor(() => + expect(screen.getByTestId('queue-conn-state')).toHaveTextContent( + /offline/i + ) + ); + expect(screen.getByTestId('queue-retry')).toBeDisabled(); + }); + + it('clear requires confirmation before calling clear()', async () => { + mockQueue.getQueue.mockResolvedValue([makeItem()]); + render(); + await waitFor(() => + expect(screen.getByTestId('queue-clear')).toBeEnabled() + ); + + fireEvent.click(screen.getByTestId('queue-clear')); + // Confirmation appears; clear NOT yet called. + expect(screen.getByTestId('queue-clear-confirm')).toBeInTheDocument(); + expect(mockQueue.clear).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('queue-clear-confirm-yes')); + await waitFor(() => expect(mockQueue.clear).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.tsx b/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.tsx new file mode 100644 index 00000000..1c9a03e1 --- /dev/null +++ b/src/components/payment/PaymentQueuePanel/PaymentQueuePanel.tsx @@ -0,0 +1,248 @@ +/** + * PaymentQueuePanel + * + * User-facing management surface for the offline payment queue (#4). The queue + * backend (paymentQueue, IndexedDB via Dexie) was already shipped; this is the + * missing UI layer: live online/offline/syncing/queued status, the queued items + * with their retry counts, a manual "Retry now" affordance (paymentQueue + * retryFailed + sync), and a destructive "Clear queue" behind a confirmation. + * + * The queue API has no change emitter, so we poll getQueue() like + * OfflineRetryBanner does. + */ + +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { useOfflineStatus } from '@/hooks/useOfflineStatus'; +import { paymentQueue } from '@/lib/offline-queue/payment-adapter'; +import { DEFAULT_QUEUE_CONFIG } from '@/lib/offline-queue/types'; +import type { PaymentQueueItem } from '@/lib/offline-queue/types'; + +export interface PaymentQueuePanelProps { + /** Override the queue-poll interval (ms). */ + pollIntervalMs?: number; + className?: string; + testId?: string; +} + +const DEFAULT_POLL_MS = 5_000; +const MAX_RETRIES = DEFAULT_QUEUE_CONFIG.maxRetries; + +type ConnState = 'online' | 'offline' | 'syncing'; + +export const PaymentQueuePanel: React.FC = ({ + pollIntervalMs = DEFAULT_POLL_MS, + className = '', + testId = 'payment-queue-panel', +}) => { + const { isOffline } = useOfflineStatus(); + const [items, setItems] = useState([]); + const [syncing, setSyncing] = useState(false); + const [confirmingClear, setConfirmingClear] = useState(false); + const [actionError, setActionError] = useState(null); + + const refresh = useCallback(async () => { + try { + const queue = await paymentQueue.getQueue(); + setItems(queue); + } catch { + // Non-critical; leave the last-known list. + } + }, []); + + useEffect(() => { + refresh(); + const id = window.setInterval(refresh, pollIntervalMs); + return () => window.clearInterval(id); + }, [refresh, pollIntervalMs]); + + const handleRetry = useCallback(async () => { + setActionError(null); + setSyncing(true); + try { + // Reset failed items to pending, then drain the queue. + await paymentQueue.retryFailed(); + await paymentQueue.sync(); + } catch (err) { + setActionError( + err instanceof Error ? err.message : 'Failed to retry queued payments' + ); + } finally { + setSyncing(false); + await refresh(); + } + }, [refresh]); + + const handleClear = useCallback(async () => { + setActionError(null); + try { + await paymentQueue.clear(); + } catch (err) { + setActionError( + err instanceof Error ? err.message : 'Failed to clear the queue' + ); + } finally { + setConfirmingClear(false); + await refresh(); + } + }, [refresh]); + + const pendingCount = items.filter( + (i) => i.status === 'pending' || i.status === 'processing' + ).length; + const failedCount = items.filter((i) => i.status === 'failed').length; + + const connState: ConnState = isOffline + ? 'offline' + : syncing + ? 'syncing' + : 'online'; + + const statusBadge = + connState === 'offline' ? ( + + Offline + + ) : connState === 'syncing' ? ( + + + Syncing + + ) : ( + + Online + + ); + + return ( +
+
+
+

Offline Payment Queue

+ {statusBadge} +
+ +

+ {items.length === 0 + ? 'No queued payments.' + : `${pendingCount} pending` + + (failedCount > 0 ? `, ${failedCount} failed` : '') + + ` (${items.length} total)`} +

+ + {actionError && ( +
+ {actionError} +
+ )} + + {items.length > 0 && ( +
    + {items.map((item) => { + const atMax = item.retries >= MAX_RETRIES; + return ( +
  • +
    + + {item.type.replace('_', ' ')} + + {item.lastError && ( + + {item.lastError} + + )} +
    +
    + + {atMax + ? 'Max retries' + : `Attempt ${item.retries}/${MAX_RETRIES}`} + +
    +
  • + ); + })} +
+ )} + +
+ + + {confirmingClear ? ( +
+ Clear all queued payments? + + +
+ ) : ( + + )} +
+
+
+ ); +}; + +PaymentQueuePanel.displayName = 'PaymentQueuePanel'; + +export default PaymentQueuePanel; diff --git a/src/components/payment/PaymentQueuePanel/index.tsx b/src/components/payment/PaymentQueuePanel/index.tsx new file mode 100644 index 00000000..83d7dfef --- /dev/null +++ b/src/components/payment/PaymentQueuePanel/index.tsx @@ -0,0 +1,2 @@ +export { default, PaymentQueuePanel } from './PaymentQueuePanel'; +export type { PaymentQueuePanelProps } from './PaymentQueuePanel'; diff --git a/tests/e2e/payment/05-offline-queue.spec.ts b/tests/e2e/payment/05-offline-queue.spec.ts index 6816e6f5..17e9328e 100644 --- a/tests/e2e/payment/05-offline-queue.spec.ts +++ b/tests/e2e/payment/05-offline-queue.spec.ts @@ -62,9 +62,44 @@ test.describe('Offline Payment Queue', () => { }); }); + test('queue management UI renders on the payment dashboard (#4)', async ({ + page, + }) => { + // The offline-queue management UI now exists (#4): PaymentQueuePanel on + // /payment/dashboard. With an empty queue the test user sees the empty + // state + disabled controls; this asserts the panel + its affordances are + // wired (no payment provider/creds needed — the queue is client-side). + await page.goto('/payment/dashboard', { waitUntil: 'networkidle' }); + if (page.url().includes('/sign-in')) { + await page.waitForTimeout(3000); + await page.goto('/payment/dashboard', { waitUntil: 'networkidle' }); + } + await dismissCookieBanner(page); + + // The panel only renders when a provider is configured (the queue itself + // works regardless). When unconfigured, assert the page at least loads. + const panel = page.getByTestId('payment-queue-panel'); + if (await panel.count()) { + await expect(panel).toBeVisible({ timeout: 30000 }); + await expect(page.getByTestId('queue-count')).toBeVisible(); + // Empty queue → retry + clear are present but disabled. + await expect(page.getByTestId('queue-retry')).toBeDisabled(); + await expect(page.getByTestId('queue-clear')).toBeDisabled(); + } else { + await expect( + page.getByRole('heading', { name: 'Payment Dashboard', level: 1 }) + ).toBeVisible({ timeout: 30000 }); + } + }); + + // The flows below drive a POPULATED queue (offline-enqueue, sync, retry, + // max-retry, overflow, clear). The UI now exists (PaymentQueuePanel on + // /payment/dashboard) — the remaining blocker is SEEDING the queue, which + // needs either a real payment attempt (provider creds) or a direct Dexie + // IndexedDB fixture. Un-skip once a queue-seed fixture lands. (Was + // "UI not yet implemented" — that part is done.) test.skip('should queue payment when offline', async ({ page, context }) => { - // Skip: Offline queue status not displayed in current UI - test.skip(true, 'Offline queue status UI not yet implemented'); + test.skip(true, 'Needs a queue-seed fixture or provider creds to enqueue'); }); test.skip('should automatically sync queue when coming online', async ({ @@ -72,7 +107,7 @@ test.describe('Offline Payment Queue', () => { context, }) => { // Skip: Queue sync status not displayed in current UI - test.skip(true, 'Queue sync status UI not yet implemented'); + test.skip(true, 'Needs a queue-seed fixture or provider creds to enqueue'); }); test.skip('should handle multiple queued payments', async ({ @@ -80,7 +115,7 @@ test.describe('Offline Payment Queue', () => { context, }) => { // Skip: Queue count not displayed in current UI - test.skip(true, 'Queue count display not yet implemented'); + test.skip(true, 'Needs a queue-seed fixture to enqueue multiple items'); }); test.skip('should persist queue across page reloads', async ({ @@ -88,7 +123,10 @@ test.describe('Offline Payment Queue', () => { context, }) => { // Skip: Queue persistence status not displayed - test.skip(true, 'Queue persistence UI not yet implemented'); + test.skip( + true, + 'Needs a queue-seed fixture (Dexie) to populate before reload' + ); }); test.skip('should retry failed queue items with exponential backoff', async ({ @@ -96,7 +134,7 @@ test.describe('Offline Payment Queue', () => { context, }) => { // Skip: Retry status not displayed - test.skip(true, 'Retry status UI not yet implemented'); + test.skip(true, 'Needs a seeded failed item to exercise backoff/retry UI'); }); test.skip('should remove queued items after max retry attempts', async ({ @@ -104,7 +142,10 @@ test.describe('Offline Payment Queue', () => { context, }) => { // Skip: Retry status not displayed - test.skip(true, 'Max retry UI not yet implemented'); + test.skip( + true, + 'Needs a seeded item at maxRetries to assert the Max-retries badge' + ); }); test.skip('should show queue status in payment history', async ({ @@ -112,7 +153,10 @@ test.describe('Offline Payment Queue', () => { context, }) => { // Skip: /payment/history route doesn't exist - test.skip(true, 'Payment history page not yet implemented'); + test.skip( + true, + 'Separate /payment/history surface; queue status lives on /payment/dashboard now' + ); }); test.skip('should handle queue overflow gracefully', async ({ @@ -120,11 +164,14 @@ test.describe('Offline Payment Queue', () => { context, }) => { // Skip: Queue overflow alert not implemented - test.skip(true, 'Queue overflow handling not yet implemented'); + test.skip(true, 'Overflow/storage-warning handling not in scope for #4'); }); test.skip('should clear queue manually', async ({ page, context }) => { // Skip: Clear queue button not implemented - test.skip(true, 'Clear queue button not yet implemented'); + test.skip( + true, + 'Clear button EXISTS (PaymentQueuePanel); needs a queue-seed fixture to assert the clear flow end-to-end' + ); }); });