Skip to content

Commit 90d4000

Browse files
authored
feat(ui): add checkout helper-signal fallback and recovery actions (#332)
1 parent d0731ca commit 90d4000

File tree

2 files changed

+114
-14
lines changed

2 files changed

+114
-14
lines changed

apps/ui/app/checkout/page.tsx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ export default function CheckoutPage() {
246246
const [isPreparingCheckout, setIsPreparingCheckout] = useState(false);
247247
const [isFinalizingPayment, setIsFinalizingPayment] = useState(false);
248248
const [isOrderPaymentConfirmed, setIsOrderPaymentConfirmed] = useState(false);
249+
const [continueWithoutHelperSignals, setContinueWithoutHelperSignals] = useState(false);
249250

250251
const hasCompletedCheckoutRef = useRef(false);
251252
const reservationIdsRef = useRef<string[]>([]);
@@ -322,6 +323,13 @@ export default function CheckoutPage() {
322323
const hasReservationQueries = reservationOutcomeQueries.length > 0;
323324
const areReservationSignalsLoading = reservationOutcomeQueries.some((query) => query.isLoading);
324325
const areReservationSignalsFetching = reservationOutcomeQueries.some((query) => query.isFetching);
326+
const hasHelperSignalsError = isInventoryHealthError || Boolean(reservationSignalsError);
327+
328+
useEffect(() => {
329+
if (!hasHelperSignalsError) {
330+
setContinueWithoutHelperSignals(false);
331+
}
332+
}, [hasHelperSignalsError]);
325333

326334
const retryInventoryHealth = () => {
327335
void refetchInventoryHealth();
@@ -331,6 +339,18 @@ export default function CheckoutPage() {
331339
void Promise.allSettled(reservationOutcomeQueries.map((query) => query.refetch()));
332340
};
333341

342+
const retryHelperSignals = () => {
343+
if (isInventoryHealthError) {
344+
retryInventoryHealth();
345+
}
346+
347+
if (reservationSignalsError) {
348+
retryReservationOutcomes();
349+
}
350+
351+
setContinueWithoutHelperSignals(false);
352+
};
353+
334354
const updateShippingField = (field: ShippingFieldKey, value: string) => {
335355
setShippingData((previous) => ({ ...previous, [field]: value }));
336356
setShippingFieldErrors((previous) => {
@@ -988,13 +1008,46 @@ export default function CheckoutPage() {
9881008
</p>
9891009
) : null}
9901010

1011+
{hasHelperSignalsError ? (
1012+
<div className="space-y-2" role="alert" aria-live="assertive">
1013+
<p className="text-sm text-red-600 dark:text-red-400">
1014+
Live inventory assistant signals are temporarily unavailable.
1015+
</p>
1016+
<div className="flex flex-wrap gap-2">
1017+
<Button
1018+
type="button"
1019+
variant="outline"
1020+
size="sm"
1021+
onClick={retryHelperSignals}
1022+
disabled={isInventoryHealthFetching || areReservationSignalsFetching}
1023+
>
1024+
Retry signal checks
1025+
</Button>
1026+
<Button
1027+
type="button"
1028+
variant="ghost"
1029+
size="sm"
1030+
onClick={() => setContinueWithoutHelperSignals(true)}
1031+
>
1032+
Continue without live signals
1033+
</Button>
1034+
</div>
1035+
</div>
1036+
) : null}
1037+
1038+
{continueWithoutHelperSignals ? (
1039+
<p className="text-sm text-gray-700 dark:text-gray-300" role="status" aria-live="polite">
1040+
Continuing without live assistant signals. Checkout remains available.
1041+
</p>
1042+
) : null}
1043+
9911044
{isInventoryHealthLoading ? (
9921045
<p className="text-sm text-gray-600 dark:text-gray-400" role="status" aria-live="polite">
9931046
Checking inventory health signals…
9941047
</p>
9951048
) : null}
9961049

997-
{isInventoryHealthError ? (
1050+
{isInventoryHealthError && !continueWithoutHelperSignals ? (
9981051
<div className="space-y-2" role="alert" aria-live="assertive">
9991052
<p className="text-sm text-red-600 dark:text-red-400">
10001053
Inventory health signals are temporarily unavailable. Retry to refresh current stock risk.
@@ -1013,7 +1066,7 @@ export default function CheckoutPage() {
10131066
</div>
10141067
) : null}
10151068

1016-
{reservationSignalsError ? (
1069+
{reservationSignalsError && !continueWithoutHelperSignals ? (
10171070
<div className="space-y-2" role="alert" aria-live="assertive">
10181071
<p className="text-sm text-red-600 dark:text-red-400">
10191072
Reservation outcomes are temporarily unavailable. Retry to confirm hold status before payment.

apps/ui/tests/unit/checkoutFlow.test.tsx

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import checkoutService from '../../lib/services/checkoutService';
55
import inventoryService from '../../lib/services/inventoryService';
66

77
const push = jest.fn();
8+
const mockUseInventoryHealth = jest.fn();
9+
const mockUseReservationOutcomeQueries = jest.fn();
810

911
jest.mock('next/navigation', () => ({
1012
useRouter: () => ({ push }),
@@ -29,18 +31,8 @@ jest.mock('../../lib/hooks/useCart', () => ({
2931
}));
3032

3133
jest.mock('../../lib/hooks/useInventory', () => ({
32-
useInventoryHealth: () => ({
33-
data: {
34-
total_skus: 1,
35-
healthy: 1,
36-
low_stock: 0,
37-
out_of_stock: 0,
38-
items: [],
39-
},
40-
isLoading: false,
41-
isError: false,
42-
}),
43-
useReservationOutcomeQueries: () => [],
34+
useInventoryHealth: (...args: unknown[]) => mockUseInventoryHealth(...args),
35+
useReservationOutcomeQueries: (...args: unknown[]) => mockUseReservationOutcomeQueries(...args),
4436
}));
4537

4638
jest.mock('../../lib/services/checkoutService', () => ({
@@ -77,6 +69,21 @@ function fillShippingForm(container: HTMLElement) {
7769
describe('CheckoutPage flow', () => {
7870
beforeEach(() => {
7971
jest.clearAllMocks();
72+
mockUseInventoryHealth.mockReturnValue({
73+
data: {
74+
total_skus: 1,
75+
healthy: 1,
76+
low_stock: 0,
77+
out_of_stock: 0,
78+
items: [],
79+
},
80+
isLoading: false,
81+
isError: false,
82+
isFetching: false,
83+
refetch: jest.fn(),
84+
});
85+
mockUseReservationOutcomeQueries.mockReturnValue([]);
86+
8087
(inventoryService.createReservation as jest.Mock).mockResolvedValue({
8188
id: 'res-1',
8289
sku: 'sku-1',
@@ -253,6 +260,46 @@ describe('CheckoutPage flow', () => {
253260
});
254261
});
255262

263+
it('supports continue-without-helper-signals and retry recovery actions when helper data fails', () => {
264+
const refetchInventoryHealth = jest.fn();
265+
const refetchReservationOutcome = jest.fn();
266+
267+
mockUseInventoryHealth.mockReturnValue({
268+
data: undefined,
269+
isLoading: false,
270+
isError: true,
271+
isFetching: false,
272+
refetch: refetchInventoryHealth,
273+
});
274+
275+
mockUseReservationOutcomeQueries.mockReturnValue([
276+
{
277+
data: undefined,
278+
isError: true,
279+
error: new Error('reservation signal failure'),
280+
isLoading: false,
281+
isFetching: false,
282+
refetch: refetchReservationOutcome,
283+
},
284+
]);
285+
286+
render(<CheckoutPage />);
287+
288+
expect(screen.getByText('Live inventory assistant signals are temporarily unavailable.')).toBeInTheDocument();
289+
expect(screen.getByRole('button', { name: 'Continue to Payment' })).toBeEnabled();
290+
291+
fireEvent.click(screen.getByRole('button', { name: 'Continue without live signals' }));
292+
293+
expect(screen.getByText('Continuing without live assistant signals. Checkout remains available.')).toBeInTheDocument();
294+
expect(screen.queryByText('Inventory health signals are temporarily unavailable. Retry to refresh current stock risk.')).not.toBeInTheDocument();
295+
expect(screen.queryByText('Reservation outcomes are temporarily unavailable. Retry to confirm hold status before payment.')).not.toBeInTheDocument();
296+
297+
fireEvent.click(screen.getByRole('button', { name: 'Retry signal checks' }));
298+
299+
expect(refetchInventoryHealth).toHaveBeenCalledTimes(1);
300+
expect(refetchReservationOutcome).toHaveBeenCalledTimes(1);
301+
});
302+
256303
it('shows explicit required and optional semantics with phone rationale text', () => {
257304
render(<CheckoutPage />);
258305

0 commit comments

Comments
 (0)