Skip to content

Commit 1df4c23

Browse files
authored
feat(predict): add World Cup route scaffold (#30127)
## **Description** Adds the dedicated Predict World Cup route scaffold required by PRED-877 and follow-up World Cup stories. This PR: - Adds World Cup deep-link parsing for `/predict?feed=world-cup` and `/predict?feed=world-cup&tab=<tabKey>`. - Preserves existing `/predict?market=...`, `/predict?marketId=...`, `/predict?tab=...`, and search deep-link behavior. - Adds `Routes.PREDICT.WORLD_CUP` to the Predict stack. - Adds a dedicated `PredictWorldCup` screen scaffold with a `World Cup` header and fixed pill tabs. - Resolves invalid/missing World Cup tabs to `All`. - Falls back to the Predict feed when the World Cup screen flag is disabled. The screen intentionally contains no market/card content yet; data population will happen in follow-up PRs. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-877 ## **Manual testing steps** ```gherkin Feature: Predict World Cup deep links Scenario: user opens the World Cup feed link Given the Predict World Cup screen flag is enabled When user opens /predict?feed=world-cup Then the World Cup screen opens with the All tab selected Scenario: user opens the World Cup feed link with a tab Given the Predict World Cup screen flag is enabled When user opens /predict?feed=world-cup&tab=live Then the World Cup screen opens with the Live tab selected Scenario: user opens a stale World Cup link Given the Predict World Cup screen flag is disabled When user opens /predict?feed=world-cup Then the app falls back to the Predict market list ``` Automated validation run: - `yarn jest app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts app/components/UI/Predict/routes/index.test.tsx app/components/UI/Predict/views/PredictWorldCup/PredictWorldCup.test.tsx --runInBand` - `yarn lint:tsc` ## **Screenshots/Recordings** ### **Before** N/A — dedicated World Cup route/screen did not exist. ### **After** https://github.com/user-attachments/assets/0605feeb-2850-4e60-a53c-1ccff872ff8b ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new navigation route and extends deeplink parsing/dispatch in `handlePredictUrl`, which can affect how users are routed from external links if mis-parsed or flags/config are wrong. Changes are gated by feature flags and covered by new unit tests, reducing but not eliminating navigation/regression risk. > > **Overview** > Adds a dedicated Predict **World Cup** entry point: a new `Routes.PREDICT.WORLD_CUP` stack screen and a `PredictWorldCup` scaffold UI with header + pill tabs, including initial-tab resolution and fallback-to-`All` behavior. > > Extends `/predict` deeplink handling to support `?feed=world-cup` (plus optional `tab=`), validating the requested tab against fixed + configured stage tabs and falling back to the normal market list when the World Cup screen is disabled. Updates navigation param types accordingly and adds tests for the new route and deeplink scenarios. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f968523. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9468597 commit 1df4c23

9 files changed

Lines changed: 634 additions & 19 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { PredictWorldCupConfig } from '../types/flags';
2+
3+
export const PREDICT_WORLD_CUP_FEED_PARAM = 'world-cup';
4+
5+
export const PREDICT_WORLD_CUP_TAB_KEYS = {
6+
ALL: 'all',
7+
LIVE: 'live',
8+
PROPS: 'props',
9+
} as const;
10+
11+
export type PredictWorldCupTabKey = string;
12+
13+
export const getPredictWorldCupAvailableTabKeys = (
14+
config?: Pick<PredictWorldCupConfig, 'stages'>,
15+
): string[] => [
16+
...Object.values(PREDICT_WORLD_CUP_TAB_KEYS),
17+
...(config?.stages ?? []).map((stage) => stage.key),
18+
];
19+
20+
export const resolvePredictWorldCupInitialTab = (
21+
requestedTab?: string | null,
22+
config?: Pick<PredictWorldCupConfig, 'stages'>,
23+
): PredictWorldCupTabKey => {
24+
const normalizedTab = requestedTab?.toLowerCase();
25+
26+
if (!normalizedTab) {
27+
return PREDICT_WORLD_CUP_TAB_KEYS.ALL;
28+
}
29+
30+
const availableTabKeys = getPredictWorldCupAvailableTabKeys(config);
31+
32+
return availableTabKeys.includes(normalizedTab)
33+
? normalizedTab
34+
: PREDICT_WORLD_CUP_TAB_KEYS.ALL;
35+
};

app/components/UI/Predict/routes/index.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ jest.mock('../views/PredictFeed', () => {
4343
);
4444
});
4545

46+
jest.mock('../views/PredictWorldCup', () => {
47+
const { View } = jest.requireActual('react-native');
48+
return () => <View testID="predict-world-cup" />;
49+
});
50+
4651
jest.mock('../views/PredictMarketDetails', () => {
4752
const { View } = jest.requireActual('react-native');
4853
return () => <View testID="predict-market-details" />;
@@ -135,6 +140,16 @@ describe('PredictScreenStack', () => {
135140
expect(screen.getByTestId('predict-market-details')).toBeOnTheScreen();
136141
});
137142

143+
it('navigates to WORLD_CUP screen', async () => {
144+
renderWithNavigation(<PredictScreenStack />);
145+
146+
await act(async () => {
147+
navigationRef.current?.navigate(Routes.PREDICT.WORLD_CUP);
148+
});
149+
150+
expect(screen.getByTestId('predict-world-cup')).toBeOnTheScreen();
151+
});
152+
138153
it('navigates to BUY_PREVIEW with PredictBuyPreview when payWithAnyToken is off', async () => {
139154
mockPayWithAnyTokenEnabled = false;
140155

app/components/UI/Predict/routes/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import PredictActivityDetail from '../components/PredictActivityDetail/PredictAc
1212
import { PredictNavigationParamList } from '../types/navigation';
1313
import PredictAddFundsModal from '../views/PredictAddFundsModal/PredictAddFundsModal';
1414
import PredictFeed from '../views/PredictFeed';
15+
import PredictWorldCup from '../views/PredictWorldCup';
1516
import PredictGTMModal from '../components/PredictGTMModal';
1617
import { Dimensions } from 'react-native';
1718
import { useSelector } from 'react-redux';
@@ -162,6 +163,15 @@ const PredictScreenStack = () => {
162163
}}
163164
/>
164165

166+
<Stack.Screen
167+
name={Routes.PREDICT.WORLD_CUP}
168+
component={PredictWorldCup}
169+
options={{
170+
headerShown: false,
171+
cardStyleInterpolator: slideFromRightInterpolator,
172+
}}
173+
/>
174+
165175
<Stack.Screen
166176
name={Routes.PREDICT.MODALS.BUY_PREVIEW}
167177
component={BuyPreviewComponent}

app/components/UI/Predict/types/navigation.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '.';
1414
import { PredictEventValues } from '../constants/eventNames';
1515
import type { TransactionActiveAbTestEntry } from '../../../../util/transactions/transaction-active-ab-test-attribution-registry';
16+
import type { PredictWorldCupTabKey } from '../constants/worldCupTabs';
1617

1718
export type PredictEntryPoint =
1819
| typeof PredictEventValues.ENTRY_POINT.CAROUSEL
@@ -47,6 +48,12 @@ export interface PredictMarketDetailsParams {
4748
transactionActiveAbTests?: TransactionActiveAbTestEntry[];
4849
}
4950

51+
/** Predict World Cup feed parameters */
52+
export interface PredictWorldCupParams {
53+
entryPoint?: string;
54+
initialTab?: PredictWorldCupTabKey;
55+
}
56+
5057
/** Predict activity detail parameters */
5158
export interface PredictActivityDetailParams {
5259
activity: PredictActivityItem;
@@ -100,7 +107,7 @@ export interface PredictNavigationParamList extends ParamListBase {
100107
Predict: undefined;
101108
PredictMarketList: PredictMarketListParams;
102109
PredictMarketDetails: PredictMarketDetailsParams;
103-
PredictWorldCup: undefined;
110+
PredictWorldCup: PredictWorldCupParams | undefined;
104111
PredictSellPreview: PredictSellPreviewParams;
105112
PredictBuyPreview: PredictBuyPreviewParams;
106113
PredictActivityDetail: PredictActivityDetailParams;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import React from 'react';
2+
import { fireEvent, render, screen } from '@testing-library/react-native';
3+
import PredictWorldCup, {
4+
PREDICT_WORLD_CUP_SCREEN_TEST_IDS,
5+
} from './PredictWorldCup';
6+
import Routes from '../../../../../constants/navigation/Routes';
7+
import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../../constants/flags';
8+
9+
const mockNavigate = jest.fn();
10+
const mockGoBack = jest.fn();
11+
const mockCanGoBack = jest.fn(() => true);
12+
let mockRouteParams: { entryPoint?: string; initialTab?: string } | undefined;
13+
let mockIsScreenEnabled = true;
14+
let mockConfig = DEFAULT_PREDICT_WORLD_CUP_FLAG;
15+
16+
jest.mock('@react-navigation/native', () => ({
17+
useNavigation: () => ({
18+
navigate: mockNavigate,
19+
goBack: mockGoBack,
20+
canGoBack: mockCanGoBack,
21+
}),
22+
useRoute: () => ({ params: mockRouteParams }),
23+
}));
24+
25+
jest.mock('react-redux', () => ({
26+
useSelector: jest.fn((selector) => {
27+
if (selector === 'selectPredictWorldCupConfig') {
28+
return mockConfig;
29+
}
30+
31+
if (selector === 'selectPredictWorldCupScreenEnabledFlag') {
32+
return mockIsScreenEnabled;
33+
}
34+
35+
return undefined;
36+
}),
37+
}));
38+
39+
jest.mock('../../selectors/featureFlags', () => ({
40+
selectPredictWorldCupConfig: 'selectPredictWorldCupConfig',
41+
selectPredictWorldCupScreenEnabledFlag:
42+
'selectPredictWorldCupScreenEnabledFlag',
43+
}));
44+
45+
describe('PredictWorldCup', () => {
46+
beforeEach(() => {
47+
jest.clearAllMocks();
48+
mockCanGoBack.mockReturnValue(true);
49+
mockRouteParams = undefined;
50+
mockIsScreenEnabled = true;
51+
mockConfig = {
52+
...DEFAULT_PREDICT_WORLD_CUP_FLAG,
53+
enabled: true,
54+
showWorldCupScreen: true,
55+
};
56+
});
57+
58+
it('renders the screen scaffold with All selected by default', () => {
59+
render(<PredictWorldCup />);
60+
61+
expect(
62+
screen.getByTestId(PREDICT_WORLD_CUP_SCREEN_TEST_IDS.CONTAINER),
63+
).toBeOnTheScreen();
64+
expect(
65+
screen.getByTestId(PREDICT_WORLD_CUP_SCREEN_TEST_IDS.INITIAL_TAB),
66+
).toHaveTextContent('all');
67+
});
68+
69+
it('renders fixed tabs and an empty content area', () => {
70+
render(<PredictWorldCup />);
71+
72+
expect(
73+
screen.getByTestId(`${PREDICT_WORLD_CUP_SCREEN_TEST_IDS.TAB}-all`),
74+
).toBeOnTheScreen();
75+
expect(
76+
screen.getByTestId(`${PREDICT_WORLD_CUP_SCREEN_TEST_IDS.TAB}-live`),
77+
).toBeOnTheScreen();
78+
expect(
79+
screen.getByTestId(`${PREDICT_WORLD_CUP_SCREEN_TEST_IDS.TAB}-props`),
80+
).toBeOnTheScreen();
81+
expect(
82+
screen.getByTestId(PREDICT_WORLD_CUP_SCREEN_TEST_IDS.EMPTY_STATE),
83+
).toBeOnTheScreen();
84+
});
85+
86+
it('uses a valid requested initial tab', () => {
87+
mockRouteParams = { initialTab: 'props' };
88+
89+
render(<PredictWorldCup />);
90+
91+
expect(
92+
screen.getByTestId(PREDICT_WORLD_CUP_SCREEN_TEST_IDS.INITIAL_TAB),
93+
).toHaveTextContent('props');
94+
});
95+
96+
it('updates active tab when a tab is pressed', () => {
97+
render(<PredictWorldCup />);
98+
99+
fireEvent.press(
100+
screen.getByTestId(`${PREDICT_WORLD_CUP_SCREEN_TEST_IDS.TAB}-live`),
101+
);
102+
103+
expect(
104+
screen.getByTestId(PREDICT_WORLD_CUP_SCREEN_TEST_IDS.INITIAL_TAB),
105+
).toHaveTextContent('live');
106+
});
107+
108+
it('uses a configured stage initial tab', () => {
109+
mockRouteParams = { initialTab: 'group-stage' };
110+
mockConfig = {
111+
...mockConfig,
112+
stages: [{ key: 'group-stage', eventIds: ['1'] }],
113+
};
114+
115+
render(<PredictWorldCup />);
116+
117+
expect(
118+
screen.getByTestId(PREDICT_WORLD_CUP_SCREEN_TEST_IDS.INITIAL_TAB),
119+
).toHaveTextContent('group-stage');
120+
});
121+
122+
it('falls back to All for an invalid requested initial tab', () => {
123+
mockRouteParams = { initialTab: 'invalid' };
124+
125+
render(<PredictWorldCup />);
126+
127+
expect(
128+
screen.getByTestId(PREDICT_WORLD_CUP_SCREEN_TEST_IDS.INITIAL_TAB),
129+
).toHaveTextContent('all');
130+
});
131+
132+
it('redirects to Predict market list when the screen is disabled', () => {
133+
mockIsScreenEnabled = false;
134+
mockRouteParams = { entryPoint: 'deeplink', initialTab: 'live' };
135+
136+
const { queryByTestId } = render(<PredictWorldCup />);
137+
138+
expect(
139+
queryByTestId(PREDICT_WORLD_CUP_SCREEN_TEST_IDS.CONTAINER),
140+
).toBeNull();
141+
expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MARKET_LIST, {
142+
entryPoint: 'deeplink',
143+
});
144+
});
145+
});

0 commit comments

Comments
 (0)