Skip to content

Commit 7e03351

Browse files
committed
feat: bottom sheet errors
1 parent 6e4e095 commit 7e03351

19 files changed

Lines changed: 1004 additions & 34 deletions

app/components/UI/Predict/Predict.testIds.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ export const PredictPositionSelectorsIDs = {
187187
export const PredictBuyPreviewSelectorsIDs = {
188188
// Buy/Place bet button
189189
PLACE_BET_BUTTON: 'predict-buy-preview-place-bet-button',
190+
191+
// Inline error banners (sheet mode)
192+
PRICE_CHANGED_BANNER: 'predict-buy-preview-price-changed-banner',
193+
ORDER_FAILED_BANNER: 'predict-buy-preview-order-failed-banner',
190194
} as const;
191195

192196
// ========================================

app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { render, screen, fireEvent } from '@testing-library/react-native';
33
import { Text, TouchableOpacity, View } from 'react-native';
44
import {
5+
isPredictSheetProviderMounted,
56
PredictPreviewSheetProvider,
67
usePredictPreviewSheet,
78
} from './PredictPreviewSheetContext';
@@ -41,6 +42,16 @@ jest.mock('react-redux', () => ({
4142
useSelector: jest.fn((selector: (state: unknown) => unknown) => selector({})),
4243
}));
4344

45+
const mockClearOrderError = jest.fn();
46+
let mockActiveOrder: { error?: string; state?: string } | null = null;
47+
48+
jest.mock('../hooks/usePredictActiveOrder', () => ({
49+
usePredictActiveOrder: jest.fn(() => ({
50+
activeOrder: mockActiveOrder,
51+
clearOrderError: mockClearOrderError,
52+
})),
53+
}));
54+
4455
jest.mock('../components/PredictPreviewSheet/PredictPreviewSheet', () => {
4556
const { forwardRef, useImperativeHandle } = jest.requireActual('react');
4657
const {
@@ -173,6 +184,7 @@ describe('PredictPreviewSheetContext', () => {
173184
jest.clearAllMocks();
174185
mockBottomSheetEnabled = true;
175186
mockPayWithAnyTokenEnabled = false;
187+
mockActiveOrder = null;
176188
mockSelectPredictBottomSheetEnabledFlag.mockImplementation(
177189
() => mockBottomSheetEnabled,
178190
);
@@ -416,4 +428,121 @@ describe('PredictPreviewSheetContext', () => {
416428
fireEvent.press(screen.getByTestId('open-sell'));
417429
expect(screen.getByTestId('predict-sell-preview-sheet')).toBeOnTheScreen();
418430
});
431+
432+
describe('background error auto-reopen', () => {
433+
it('reopens the buy sheet when activeOrder.error appears after dismiss', () => {
434+
const { rerender } = render(
435+
<PredictPreviewSheetProvider>
436+
<TestConsumer />
437+
</PredictPreviewSheetProvider>,
438+
);
439+
440+
fireEvent.press(screen.getByTestId('open-buy'));
441+
expect(screen.getByTestId('predict-buy-preview-sheet')).toBeOnTheScreen();
442+
443+
fireEvent.press(screen.getByTestId('dismiss-sheet'));
444+
expect(
445+
screen.queryByTestId('predict-buy-preview-sheet'),
446+
).not.toBeOnTheScreen();
447+
448+
mockActiveOrder = { error: 'order/failed' };
449+
rerender(
450+
<PredictPreviewSheetProvider>
451+
<TestConsumer />
452+
</PredictPreviewSheetProvider>,
453+
);
454+
455+
expect(screen.getByTestId('predict-buy-preview-sheet')).toBeOnTheScreen();
456+
});
457+
458+
it('does not auto-reopen when bottomSheetEnabled is OFF', () => {
459+
mockBottomSheetEnabled = false;
460+
const { rerender } = render(
461+
<PredictPreviewSheetProvider>
462+
<TestConsumer />
463+
</PredictPreviewSheetProvider>,
464+
);
465+
466+
// navigation flow when flag is OFF - openBuySheet still records lastBuyParamsRef
467+
fireEvent.press(screen.getByTestId('open-buy'));
468+
469+
mockActiveOrder = { error: 'order/failed' };
470+
rerender(
471+
<PredictPreviewSheetProvider>
472+
<TestConsumer />
473+
</PredictPreviewSheetProvider>,
474+
);
475+
476+
expect(
477+
screen.queryByTestId('predict-buy-preview-sheet'),
478+
).not.toBeOnTheScreen();
479+
});
480+
481+
it('does not auto-reopen if no buy was previously opened', () => {
482+
const { rerender } = render(
483+
<PredictPreviewSheetProvider>
484+
<TestConsumer />
485+
</PredictPreviewSheetProvider>,
486+
);
487+
488+
mockActiveOrder = { error: 'order/failed' };
489+
rerender(
490+
<PredictPreviewSheetProvider>
491+
<TestConsumer />
492+
</PredictPreviewSheetProvider>,
493+
);
494+
495+
expect(
496+
screen.queryByTestId('predict-buy-preview-sheet'),
497+
).not.toBeOnTheScreen();
498+
});
499+
});
500+
501+
describe('isPredictSheetProviderMounted', () => {
502+
it('returns false when provider is not mounted', () => {
503+
expect(isPredictSheetProviderMounted()).toBe(false);
504+
});
505+
506+
it('returns true while provider is mounted and false after unmount', () => {
507+
const { unmount } = render(
508+
<PredictPreviewSheetProvider>
509+
<TestConsumer />
510+
</PredictPreviewSheetProvider>,
511+
);
512+
513+
expect(isPredictSheetProviderMounted()).toBe(true);
514+
515+
unmount();
516+
517+
expect(isPredictSheetProviderMounted()).toBe(false);
518+
});
519+
});
520+
521+
describe('clearOrderError on dismiss', () => {
522+
it('calls clearOrderError when buy sheet is dismissed', () => {
523+
render(
524+
<PredictPreviewSheetProvider>
525+
<TestConsumer />
526+
</PredictPreviewSheetProvider>,
527+
);
528+
529+
fireEvent.press(screen.getByTestId('open-buy'));
530+
fireEvent.press(screen.getByTestId('dismiss-sheet'));
531+
532+
expect(mockClearOrderError).toHaveBeenCalledTimes(1);
533+
});
534+
535+
it('does not call clearOrderError when sell sheet is dismissed', () => {
536+
render(
537+
<PredictPreviewSheetProvider>
538+
<TestConsumer />
539+
</PredictPreviewSheetProvider>,
540+
);
541+
542+
fireEvent.press(screen.getByTestId('open-sell'));
543+
fireEvent.press(screen.getByTestId('dismiss-sheet'));
544+
545+
expect(mockClearOrderError).not.toHaveBeenCalled();
546+
});
547+
});
419548
});

app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ import PredictBuyPreview from '../views/PredictBuyPreview/PredictBuyPreview';
3737
import PredictBuyWithAnyToken from '../views/PredictBuyWithAnyToken/PredictBuyWithAnyToken';
3838
import PredictSellPreview from '../views/PredictSellPreview/PredictSellPreview';
3939
import { PredictMarketDetailsSelectorsIDs } from '../Predict.testIds';
40+
import { usePredictActiveOrder } from '../hooks/usePredictActiveOrder';
41+
42+
let _providerMounted = false;
43+
44+
/**
45+
* Returns whether `PredictPreviewSheetProvider` is currently mounted somewhere
46+
* in the tree. Used by `usePredictToastRegistrations` to decide whether to
47+
* suppress the order-failure toast (the provider auto-reopens the sheet with
48+
* an inline error banner instead).
49+
*/
50+
export function isPredictSheetProviderMounted(): boolean {
51+
return _providerMounted;
52+
}
4053

4154
const SellSheetHeader: React.FC<{ params: PredictSellPreviewParams }> = ({
4255
params,
@@ -140,6 +153,7 @@ export const PredictPreviewSheetProvider: React.FC<
140153
const payWithAnyTokenEnabled = useSelector(
141154
selectPredictWithAnyTokenEnabledFlag,
142155
);
156+
const { activeOrder, clearOrderError } = usePredictActiveOrder();
143157

144158
const buySheetRef = useRef<PredictPreviewSheetRef>(null);
145159
const sellSheetRef = useRef<PredictPreviewSheetRef>(null);
@@ -154,8 +168,23 @@ export const PredictPreviewSheetProvider: React.FC<
154168
const [buyNonce, setBuyNonce] = useState(0);
155169
const [sellNonce, setSellNonce] = useState(0);
156170

171+
/**
172+
* Remembers the last params passed to `openBuySheet` so we can auto-reopen
173+
* the sheet when an order fails in the background after the sheet was
174+
* dismissed (e.g. closed during DEPOSITING).
175+
*/
176+
const lastBuyParamsRef = useRef<PredictBuyPreviewParams | null>(null);
177+
178+
useEffect(() => {
179+
_providerMounted = true;
180+
return () => {
181+
_providerMounted = false;
182+
};
183+
}, []);
184+
157185
const openBuySheet = useCallback(
158186
(params: PredictBuyPreviewParams) => {
187+
lastBuyParamsRef.current = params;
159188
if (bottomSheetEnabled) {
160189
setBuyParams(params);
161190
buyNonceRef.current += 1;
@@ -192,12 +221,32 @@ export const PredictPreviewSheetProvider: React.FC<
192221
}
193222
}, [sellParams, sellNonce]);
194223

224+
// Auto-reopen the buy sheet when a background order fails so the user can
225+
// see the inline error banner and retry instead of getting a toast.
226+
useEffect(() => {
227+
if (
228+
bottomSheetEnabled &&
229+
activeOrder?.error &&
230+
!buyParams &&
231+
lastBuyParamsRef.current
232+
) {
233+
const savedParams = lastBuyParamsRef.current;
234+
lastBuyParamsRef.current = null;
235+
setBuyParams(savedParams);
236+
buyNonceRef.current += 1;
237+
setBuyNonce(buyNonceRef.current);
238+
}
239+
}, [activeOrder?.error, buyParams, bottomSheetEnabled]);
240+
195241
const BuyComponent = useMemo(
196242
() => (payWithAnyTokenEnabled ? PredictBuyWithAnyToken : PredictBuyPreview),
197243
[payWithAnyTokenEnabled],
198244
);
199245

200-
const onBuyDismiss = useCallback(() => setBuyParams(null), []);
246+
const onBuyDismiss = useCallback(() => {
247+
setBuyParams(null);
248+
clearOrderError();
249+
}, [clearOrderError]);
201250
const onSellDismiss = useCallback(() => setSellParams(null), []);
202251

203252
const contextValue = React.useMemo(

app/components/UI/Predict/contexts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
} from './PredictEntryPointContext';
55

66
export {
7+
isPredictSheetProviderMounted,
78
PredictPreviewSheetProvider,
89
usePredictPreviewSheet,
910
} from './PredictPreviewSheetContext';

app/components/UI/Predict/hooks/usePredictOrderRetry.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function createParamsObj(overrides?: Record<string, unknown>) {
5858
} as PlaceOrderParams['analyticsProperties'],
5959
isOrderNotFilled: false,
6060
resetOrderNotFilled: jest.fn(),
61+
isSheetMode: false as boolean | undefined,
6162
...overrides,
6263
};
6364
}
@@ -131,6 +132,61 @@ describe('usePredictOrderRetry', () => {
131132
);
132133
expect(promptedCalls).toHaveLength(1);
133134
});
135+
136+
it('opens the retry sheet ref when isOrderNotFilled becomes true and isSheetMode is false', () => {
137+
const params = createDefaultParams({ isOrderNotFilled: false });
138+
const { result, rerender } = renderHook(() =>
139+
usePredictOrderRetry(params),
140+
);
141+
const onOpen = jest.fn();
142+
// simulate ref attached by the rendered sheet
143+
(
144+
result.current.retrySheetRef as unknown as {
145+
current: { onOpenBottomSheet: jest.Mock };
146+
}
147+
).current = { onOpenBottomSheet: onOpen };
148+
149+
params.isOrderNotFilled = true;
150+
rerender();
151+
152+
expect(onOpen).toHaveBeenCalledTimes(1);
153+
});
154+
155+
it('skips opening the retry sheet ref when isSheetMode is true', () => {
156+
const params = createDefaultParams({
157+
isOrderNotFilled: false,
158+
isSheetMode: true,
159+
});
160+
const { result, rerender } = renderHook(() =>
161+
usePredictOrderRetry(params),
162+
);
163+
const onOpen = jest.fn();
164+
(
165+
result.current.retrySheetRef as unknown as {
166+
current: { onOpenBottomSheet: jest.Mock };
167+
}
168+
).current = { onOpenBottomSheet: onOpen };
169+
170+
params.isOrderNotFilled = true;
171+
rerender();
172+
173+
expect(onOpen).not.toHaveBeenCalled();
174+
});
175+
176+
it('still tracks RETRY_PROMPTED in sheet mode', () => {
177+
const params = createDefaultParams({
178+
isOrderNotFilled: false,
179+
isSheetMode: true,
180+
});
181+
const { rerender } = renderHook(() => usePredictOrderRetry(params));
182+
183+
params.isOrderNotFilled = true;
184+
rerender();
185+
186+
expect(mockTrackEvent).toHaveBeenCalledWith(
187+
expect.objectContaining({ status: 'retry_prompted' }),
188+
);
189+
});
134190
});
135191

136192
describe('handleRetryWithBestPrice', () => {
@@ -273,6 +329,50 @@ describe('usePredictOrderRetry', () => {
273329
expect(result.current.retrySheetVariant).toBe('failed');
274330
});
275331

332+
it('opens the retry sheet on retry failure when isSheetMode is false', async () => {
333+
const mockPlaceOrder = jest
334+
.fn()
335+
.mockRejectedValue(new Error('unexpected error'));
336+
const params = createDefaultParams({ placeOrder: mockPlaceOrder });
337+
const { result } = renderHook(() => usePredictOrderRetry(params));
338+
const onOpen = jest.fn();
339+
(
340+
result.current.retrySheetRef as unknown as {
341+
current: { onOpenBottomSheet: jest.Mock };
342+
}
343+
).current = { onOpenBottomSheet: onOpen };
344+
345+
await act(async () => {
346+
await result.current.handleRetryWithBestPrice();
347+
});
348+
349+
expect(onOpen).toHaveBeenCalledTimes(1);
350+
});
351+
352+
it('does not open the retry sheet on retry failure when isSheetMode is true', async () => {
353+
const mockPlaceOrder = jest
354+
.fn()
355+
.mockRejectedValue(new Error('unexpected error'));
356+
const params = createDefaultParams({
357+
placeOrder: mockPlaceOrder,
358+
isSheetMode: true,
359+
});
360+
const { result } = renderHook(() => usePredictOrderRetry(params));
361+
const onOpen = jest.fn();
362+
(
363+
result.current.retrySheetRef as unknown as {
364+
current: { onOpenBottomSheet: jest.Mock };
365+
}
366+
).current = { onOpenBottomSheet: onOpen };
367+
368+
await act(async () => {
369+
await result.current.handleRetryWithBestPrice();
370+
});
371+
372+
expect(onOpen).not.toHaveBeenCalled();
373+
expect(result.current.retrySheetVariant).toBe('failed');
374+
});
375+
276376
it('does not call placeOrder when preview is null', async () => {
277377
const mockPlaceOrder = jest.fn();
278378
const params = createDefaultParams({

0 commit comments

Comments
 (0)