Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/app/payment-result/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import ProtectedRoute from '@/components/auth/ProtectedRoute';
import { PaymentStatusDisplay } from '@/components/payment/PaymentStatusDisplay/PaymentStatusDisplay';
import { OfflineRetryBanner } from '@/components/payment/OfflineRetryBanner';
import { featureFlags } from '@/config/payment';
import { getPaymentStatus } from '@/lib/payments/payment-service';

Expand Down Expand Up @@ -210,6 +211,7 @@ function PaymentResultContent() {
return (
<main className="container mx-auto px-4 py-6 sm:px-6 sm:py-8 md:py-12 lg:px-8">
<div className="flex flex-col items-center gap-6 text-center">
<OfflineRetryBanner className="max-w-lg" />
<div role="status" className="alert alert-info max-w-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
Expand Down Expand Up @@ -246,6 +248,7 @@ function PaymentResultContent() {
</div>

<div className="max-w-lg">
<OfflineRetryBanner className="mb-4" />
<PaymentStatusDisplay
paymentResultId={state.resultId}
showDetails
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* OfflineRetryBanner Accessibility Tests
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { OfflineRetryBanner } from './OfflineRetryBanner';

expect.extend(toHaveNoViolations);

vi.mock('@/hooks/useOfflineStatus', () => ({
useOfflineStatus: vi.fn(() => ({
isOffline: true,
wasOffline: false,
lastOnline: null,
connectionSpeed: 'unknown',
})),
}));

vi.mock('@/lib/offline-queue/payment-adapter', () => ({
paymentQueue: {
getCount: vi.fn(() => Promise.resolve(2)),
},
}));

describe('OfflineRetryBanner Accessibility', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('has no a11y violations in offline state with queued items', async () => {
const { container } = render(<OfflineRetryBanner />);
await waitFor(() => container.querySelector('.alert'));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* OfflineRetryBanner Storybook stories
*/

import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { OfflineRetryBanner } from './OfflineRetryBanner';

const meta: Meta<typeof OfflineRetryBanner> = {
title: 'Features/Payment/OfflineRetryBanner',
component: OfflineRetryBanner,
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Renders on the payment-result page when the user is offline or when queued payments are still syncing. Stays silent in the online + empty-queue steady state.',
},
},
},
};

export default meta;
type Story = StoryObj<typeof OfflineRetryBanner>;

/** Default render — relies on real online-status + empty queue. In the
* Storybook iframe this typically renders nothing (the steady state). */
export const Default: Story = {};

/** Story name = scenario; the actual offline / queued visuals are easier
* to verify by toggling devtools "Offline" while viewing the running app
* at /payment-result, since this component reads navigator.onLine and
* an IndexedDB-backed queue that Storybook doesn't simulate. */
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* OfflineRetryBanner unit tests
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { OfflineRetryBanner } from './OfflineRetryBanner';
import { useOfflineStatus } from '@/hooks/useOfflineStatus';

vi.mock('@/hooks/useOfflineStatus');

const getCountMock = vi.fn();
vi.mock('@/lib/offline-queue/payment-adapter', () => ({
paymentQueue: {
getCount: () => getCountMock(),
},
}));

beforeEach(() => {
vi.clearAllMocks();
getCountMock.mockResolvedValue(0);
});

function setOnline(isOffline: boolean) {
vi.mocked(useOfflineStatus).mockReturnValue({
isOffline,
wasOffline: false,
lastOnline: null,
connectionSpeed: 'unknown',
});
}

describe('OfflineRetryBanner', () => {
it('renders nothing when online and queue is empty (steady state)', async () => {
setOnline(false);
getCountMock.mockResolvedValue(0);
const { container } = render(<OfflineRetryBanner />);
// wait for the initial getCount to settle
await waitFor(() => expect(getCountMock).toHaveBeenCalled());
expect(container.firstChild).toBeNull();
});

it('shows offline banner with retry promise when offline (no queued items)', async () => {
setOnline(true);
getCountMock.mockResolvedValue(0);
render(<OfflineRetryBanner />);
await waitFor(() => expect(getCountMock).toHaveBeenCalled());
expect(screen.getByText(/you.?re offline/i)).toBeInTheDocument();
expect(
screen.getByText(/we.?ll process your payment/i)
).toBeInTheDocument();
// No "waiting" copy when count is 0.
expect(screen.queryByText(/waiting to send/i)).not.toBeInTheDocument();
});

it('shows queued count in offline banner (singular form)', async () => {
setOnline(true);
getCountMock.mockResolvedValue(1);
render(<OfflineRetryBanner />);
await waitFor(() =>
expect(screen.getByText(/1 payment is waiting/i)).toBeInTheDocument()
);
});

it('shows queued count in offline banner (plural form)', async () => {
setOnline(true);
getCountMock.mockResolvedValue(3);
render(<OfflineRetryBanner />);
await waitFor(() =>
expect(screen.getByText(/3 payments are waiting/i)).toBeInTheDocument()
);
});

it('shows "Syncing N queued payments" when online but queue still has items', async () => {
setOnline(false);
getCountMock.mockResolvedValue(2);
render(<OfflineRetryBanner />);
await waitFor(() =>
expect(screen.getByText(/syncing 2 queued payments/i)).toBeInTheDocument()
);
expect(screen.getByRole('status')).toBeInTheDocument();
});

it('survives a getCount rejection without throwing', async () => {
setOnline(false);
getCountMock.mockRejectedValue(new Error('IndexedDB locked'));
render(<OfflineRetryBanner />);
await waitFor(() => expect(getCountMock).toHaveBeenCalled());
// Component still renders something; specifically, online + count=0
// is the steady state, so the banner is null. The test passes if
// render didn't throw.
expect(true).toBe(true);
});
});
136 changes: 136 additions & 0 deletions src/components/payment/OfflineRetryBanner/OfflineRetryBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* OfflineRetryBanner
*
* Surfaces offline state on the payment-result page so users understand
* why their retry isn't going through. Reads `useOfflineStatus` for the
* connectivity bit and `paymentQueue.getCount()` for the count of pending
* queued payments. When queued items exist and the user is offline, the
* banner promises retry-when-online; otherwise it stays out of the way.
*
* Mount above PaymentStatusDisplay on /payment-result.
*/

'use client';

import React from 'react';
import { useOfflineStatus } from '@/hooks/useOfflineStatus';
import { paymentQueue } from '@/lib/offline-queue/payment-adapter';

export interface OfflineRetryBannerProps {
/** Override the default polling interval for the queue count (ms). */
pollIntervalMs?: number;
className?: string;
}

const DEFAULT_POLL_MS = 5_000;

export const OfflineRetryBanner: React.FC<OfflineRetryBannerProps> = ({
pollIntervalMs = DEFAULT_POLL_MS,
className = '',
}) => {
const { isOffline } = useOfflineStatus();
const [queuedCount, setQueuedCount] = React.useState<number>(0);

// Poll the queue count rather than subscribing — the queue API is Dexie
// and doesn't expose a change emitter, and the count is small + cheap.
React.useEffect(() => {
let cancelled = false;

async function refresh() {
try {
const count = await paymentQueue.getCount();
if (!cancelled) setQueuedCount(count);
} catch {
// Queue read failure is non-critical — banner just stays at 0.
}
}

refresh();
const id = window.setInterval(refresh, pollIntervalMs);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [pollIntervalMs]);

// Render nothing if there's nothing useful to say. The banner is meant
// to be silent when the user is online and the queue is empty — which
// is the expected steady state.
if (!isOffline && queuedCount === 0) {
return null;
}

if (isOffline) {
return (
<div
className={`alert alert-warning ${className}`}
role="status"
aria-live="polite"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 5.636a9 9 0 010 12.728m-3.536-3.536a4 4 0 010-5.656M5.636 5.636a9 9 0 000 12.728m3.536-3.536a4 4 0 000-5.656"
/>
</svg>
<div>
<p className="font-semibold">You&rsquo;re offline.</p>
<p className="text-sm">
We&rsquo;ll process your payment when your connection returns.
{queuedCount > 0 && (
<>
{' '}
{queuedCount === 1
? '1 payment is waiting to send.'
: `${queuedCount} payments are waiting to send.`}
</>
)}
</p>
</div>
</div>
);
}

// Online but with queued items — happens briefly during sync, or when
// the queue is in a stuck state. Surfaces it so the user isn't left
// wondering why their action seemed to take effect but didn't show up.
return (
<div
className={`alert alert-info ${className}`}
role="status"
aria-live="polite"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>
Syncing{' '}
{queuedCount === 1
? '1 queued payment'
: `${queuedCount} queued payments`}
&hellip;
</span>
</div>
);
};

OfflineRetryBanner.displayName = 'OfflineRetryBanner';
6 changes: 6 additions & 0 deletions src/components/payment/OfflineRetryBanner/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* OfflineRetryBanner Component Barrel Export
*/

export { OfflineRetryBanner } from './OfflineRetryBanner';
export type { OfflineRetryBannerProps } from './OfflineRetryBanner';
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ vi.mock('@/hooks/usePaymentRealtime', () => ({
vi.mock('@/lib/payments/payment-service', () => ({
formatPaymentAmount: vi.fn(() => '$20.00'),
retryFailedPayment: vi.fn(() => Promise.resolve({ id: 'new-intent-123' })),
RETRY_LIMIT: 3,
COOLING_PERIOD_MS: 30_000,
PaymentRetryLimitError: class extends Error {},
PaymentRetryCoolingError: class extends Error {
waitMs = 0;
},
PaymentRetryExpiredError: class extends Error {},
}));

// Mock retry-status hook so accessibility scenarios stay deterministic.
vi.mock('@/hooks/usePaymentRetryStatus', () => ({
usePaymentRetryStatus: vi.fn(() => ({
loading: false,
retryCount: 0,
maxRetries: 3,
canRetry: true,
disabledReason: null,
coolingMsRemaining: 0,
})),
}));

describe('PaymentStatusDisplay Accessibility', () => {
Expand Down
Loading
Loading