Skip to content

Commit 429fcd7

Browse files
TortoiseWolfeTurtleWolfeclaude
authored
feat(payment): #4 offline payment-queue management UI (#141)
The offline payment queue (paymentQueue, IndexedDB) shipped long ago but had no user-facing controls — OfflineRetryBanner is display-only and lives only on /payment-result. This adds the management layer: - PaymentQueuePanel (5-file molecular): live online/offline/syncing status, queued items with per-item retry count (Attempt N/5 → "Max retries" at the cap), a manual "Retry now" (paymentQueue.retryFailed + sync, disabled offline / when empty), and a "Clear queue" behind an inline confirmation (paymentQueue.clear). Targets paymentQueue, not the messaging queue. - Wired into /payment/dashboard behind the existing provider feature-flag gate. - E2E: un-skipped a real "queue UI renders on the dashboard" test (no creds needed — the queue is client-side); rewrote the 8 stale "UI not yet implemented" skip reasons to the actual remaining blocker (a queue-seed fixture / provider creds), since the UI now exists. Verification: type-check, lint, structure (110/110), build green; 7 component tests (empty state, item+retry-count rendering, retry→retryFailed+sync, offline-disables-retry, clear-needs-confirmation) pass. Co-authored-by: TurtleWolfe <TurtleWolfe@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3997dc5 commit 429fcd7

7 files changed

Lines changed: 526 additions & 10 deletions

File tree

src/app/payment/dashboard/PaymentDashboardContent.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import React from 'react';
44
import { PaymentHistory } from '@/components/payment/PaymentHistory';
5+
import { PaymentQueuePanel } from '@/components/payment/PaymentQueuePanel';
56
import { featureFlags } from '@/config/payment';
67

78
/**
@@ -43,6 +44,18 @@ export function PaymentDashboardContent() {
4344
</div>
4445
)}
4546

47+
{/* Offline payment queue management (#4). Shown when a provider is
48+
configured — the queue itself works offline regardless, but the panel
49+
is only meaningful where payments can actually be made. */}
50+
{!noProvidersConfigured && (
51+
<section aria-labelledby="payment-queue-heading">
52+
<h2 id="payment-queue-heading" className="sr-only">
53+
Offline payment queue
54+
</h2>
55+
<PaymentQueuePanel />
56+
</section>
57+
)}
58+
4659
<section aria-labelledby="payment-history-heading">
4760
<h2 id="payment-history-heading" className="mb-4 text-2xl font-bold">
4861
Payment History
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, waitFor } from '@testing-library/react';
3+
import { axe, toHaveNoViolations } from 'jest-axe';
4+
import PaymentQueuePanel from './PaymentQueuePanel';
5+
import type { PaymentQueueItem } from '@/lib/offline-queue/types';
6+
7+
expect.extend(toHaveNoViolations);
8+
9+
const items: PaymentQueueItem[] = [
10+
{
11+
id: 1,
12+
status: 'failed',
13+
retries: 2,
14+
createdAt: Date.now(),
15+
type: 'payment_intent',
16+
data: { amount: 1000 },
17+
lastError: 'Network error',
18+
} as PaymentQueueItem,
19+
];
20+
21+
let queueData: PaymentQueueItem[] = [];
22+
23+
vi.mock('@/lib/offline-queue/payment-adapter', () => ({
24+
paymentQueue: {
25+
getQueue: () => Promise.resolve(queueData),
26+
retryFailed: () => Promise.resolve(0),
27+
sync: () =>
28+
Promise.resolve({ success: 0, failed: 0, skipped: 0, conflicted: 0 }),
29+
clear: () => Promise.resolve(),
30+
},
31+
}));
32+
33+
vi.mock('@/hooks/useOfflineStatus', () => ({
34+
useOfflineStatus: () => ({ isOffline: false }),
35+
}));
36+
37+
describe('PaymentQueuePanel Accessibility', () => {
38+
beforeEach(() => {
39+
queueData = [];
40+
});
41+
42+
it('has no violations in the empty state', async () => {
43+
const { container } = render(<PaymentQueuePanel pollIntervalMs={999999} />);
44+
await waitFor(() => expect(container.firstChild).toBeInTheDocument());
45+
expect(await axe(container)).toHaveNoViolations();
46+
});
47+
48+
it('has no violations with queued items', async () => {
49+
queueData = items;
50+
const { container, findByTestId } = render(
51+
<PaymentQueuePanel pollIntervalMs={999999} />
52+
);
53+
await findByTestId('queue-items');
54+
expect(await axe(container)).toHaveNoViolations();
55+
});
56+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2+
import PaymentQueuePanel from './PaymentQueuePanel';
3+
4+
const meta: Meta<typeof PaymentQueuePanel> = {
5+
title: 'Features/Payment/PaymentQueuePanel',
6+
component: PaymentQueuePanel,
7+
parameters: {
8+
layout: 'padded',
9+
docs: {
10+
description: {
11+
component:
12+
'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.',
13+
},
14+
},
15+
},
16+
tags: ['autodocs'],
17+
};
18+
19+
export default meta;
20+
type Story = StoryObj<typeof meta>;
21+
22+
export const Default: Story = {
23+
args: { pollIntervalMs: 999999 },
24+
};
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import PaymentQueuePanel from './PaymentQueuePanel';
4+
import type { PaymentQueueItem } from '@/lib/offline-queue/types';
5+
6+
const mockQueue = {
7+
getQueue: vi.fn(),
8+
retryFailed: vi.fn(),
9+
sync: vi.fn(),
10+
clear: vi.fn(),
11+
};
12+
let offline = false;
13+
14+
vi.mock('@/lib/offline-queue/payment-adapter', () => ({
15+
paymentQueue: {
16+
getQueue: (...a: unknown[]) => mockQueue.getQueue(...a),
17+
retryFailed: (...a: unknown[]) => mockQueue.retryFailed(...a),
18+
sync: (...a: unknown[]) => mockQueue.sync(...a),
19+
clear: (...a: unknown[]) => mockQueue.clear(...a),
20+
},
21+
}));
22+
23+
vi.mock('@/hooks/useOfflineStatus', () => ({
24+
useOfflineStatus: () => ({ isOffline: offline }),
25+
}));
26+
27+
function makeItem(over: Partial<PaymentQueueItem> = {}): PaymentQueueItem {
28+
return {
29+
id: 1,
30+
status: 'pending',
31+
retries: 0,
32+
createdAt: Date.now(),
33+
type: 'payment_intent',
34+
data: { amount: 1000 },
35+
...over,
36+
} as PaymentQueueItem;
37+
}
38+
39+
describe('PaymentQueuePanel', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
offline = false;
43+
mockQueue.getQueue.mockResolvedValue([]);
44+
mockQueue.retryFailed.mockResolvedValue(0);
45+
mockQueue.sync.mockResolvedValue({
46+
success: 0,
47+
failed: 0,
48+
skipped: 0,
49+
conflicted: 0,
50+
});
51+
mockQueue.clear.mockResolvedValue(undefined);
52+
});
53+
54+
it('shows the empty state when the queue is empty', async () => {
55+
render(<PaymentQueuePanel pollIntervalMs={999999} />);
56+
await waitFor(() =>
57+
expect(screen.getByTestId('queue-count')).toHaveTextContent(
58+
/no queued payments/i
59+
)
60+
);
61+
expect(screen.getByTestId('queue-retry')).toBeDisabled();
62+
expect(screen.getByTestId('queue-clear')).toBeDisabled();
63+
});
64+
65+
it('lists queued items with their retry counts', async () => {
66+
mockQueue.getQueue.mockResolvedValue([
67+
makeItem({ id: 1, retries: 2 }),
68+
makeItem({ id: 2, status: 'failed', retries: 5, lastError: 'boom' }),
69+
]);
70+
render(<PaymentQueuePanel pollIntervalMs={999999} />);
71+
await waitFor(() =>
72+
expect(screen.getByTestId('queue-items')).toBeInTheDocument()
73+
);
74+
expect(screen.getByTestId('queue-item-retries-1')).toHaveTextContent(
75+
/attempt 2\/5/i
76+
);
77+
// At max retries → "Max retries"
78+
expect(screen.getByTestId('queue-item-retries-2')).toHaveTextContent(
79+
/max retries/i
80+
);
81+
expect(screen.getByText('boom')).toBeInTheDocument();
82+
expect(screen.getByTestId('queue-count')).toHaveTextContent(/1 pending/i);
83+
expect(screen.getByTestId('queue-count')).toHaveTextContent(/1 failed/i);
84+
});
85+
86+
it('retry calls retryFailed then sync', async () => {
87+
mockQueue.getQueue.mockResolvedValue([
88+
makeItem({ status: 'failed', retries: 1 }),
89+
]);
90+
render(<PaymentQueuePanel pollIntervalMs={999999} />);
91+
await waitFor(() =>
92+
expect(screen.getByTestId('queue-retry')).toBeEnabled()
93+
);
94+
fireEvent.click(screen.getByTestId('queue-retry'));
95+
await waitFor(() => expect(mockQueue.retryFailed).toHaveBeenCalledTimes(1));
96+
expect(mockQueue.sync).toHaveBeenCalledTimes(1);
97+
});
98+
99+
it('disables Retry now when offline', async () => {
100+
offline = true;
101+
mockQueue.getQueue.mockResolvedValue([makeItem()]);
102+
render(<PaymentQueuePanel pollIntervalMs={999999} />);
103+
await waitFor(() =>
104+
expect(screen.getByTestId('queue-conn-state')).toHaveTextContent(
105+
/offline/i
106+
)
107+
);
108+
expect(screen.getByTestId('queue-retry')).toBeDisabled();
109+
});
110+
111+
it('clear requires confirmation before calling clear()', async () => {
112+
mockQueue.getQueue.mockResolvedValue([makeItem()]);
113+
render(<PaymentQueuePanel pollIntervalMs={999999} />);
114+
await waitFor(() =>
115+
expect(screen.getByTestId('queue-clear')).toBeEnabled()
116+
);
117+
118+
fireEvent.click(screen.getByTestId('queue-clear'));
119+
// Confirmation appears; clear NOT yet called.
120+
expect(screen.getByTestId('queue-clear-confirm')).toBeInTheDocument();
121+
expect(mockQueue.clear).not.toHaveBeenCalled();
122+
123+
fireEvent.click(screen.getByTestId('queue-clear-confirm-yes'));
124+
await waitFor(() => expect(mockQueue.clear).toHaveBeenCalledTimes(1));
125+
});
126+
});

0 commit comments

Comments
 (0)