Skip to content

Commit d70c5b6

Browse files
authored
feat(predict): add World Cup feed banner (#30070)
## **Description** Adds the Predict World Cup promotional banner entry point to the main Predict feed for PRED-880. This change: - Renders the banner only when `predictWorldCup.enabled`, `showMainFeedBanner`, and `showWorldCupScreen` are enabled. - Uses the feature flag `bannerImageUrl` when present. - Falls back to the bundled Figma banner asset when no remote image URL is configured. - Navigates users to `Routes.PREDICT.WORLD_CUP` when tapped. - Adds unit coverage for enabled, disabled, missing-image, remote-image, and tap behavior. ## **Changelog** CHANGELOG entry: Added a World Cup promotional banner to the Predict feed. ## **Related issues** Fixes: [PRED-880](https://consensyssoftware.atlassian.net/browse/PRED-880) ## **Manual testing steps** ```gherkin Feature: Predict World Cup main feed banner Scenario: user opens the World Cup feed from the Predict feed banner Given the Predict World Cup feature flag is enabled And showMainFeedBanner is enabled And showWorldCupScreen is enabled And no bannerImageUrl is configured When user opens the main Predict feed Then the bundled World Cup banner is shown above the Predict feed tabs When user taps the banner Then the app navigates to the Predict World Cup screen ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** <img width="320" alt="Simulator Screenshot - mm-blue - 2026-05-12 at 16 03 50" src="https://github.com/user-attachments/assets/54ba08e7-7696-446b-b3e6-e315466781a6" /> ## **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 - [ ] 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. ## Validation - `yarn lint:tsc --pretty false` - `NODE_OPTIONS=--max-old-space-size=8192 yarn jest app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.test.tsx app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx --runInBand --forceExit` [PRED-880]: https://consensyssoftware.atlassian.net/browse/PRED-880?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI-only change gated behind existing Predict World Cup feature flags, plus a new navigation route constant/type; minimal impact when flags are off. > > **Overview** > Adds a new `PredictWorldCupMainFeedBanner` component to the main Predict feed header as a **feature-flagged** promo entry point, rendering only when `predictWorldCup.enabled`, `showMainFeedBanner`, and `showWorldCupScreen` are true. > > The banner supports a remotely configured `bannerImageUrl` (trimmed) with a bundled image fallback, and navigates to the new `Routes.PREDICT.WORLD_CUP` / `PredictWorldCup` route on tap. Includes unit tests covering render gating, image source selection, and navigation behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1ed2978. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent df201b1 commit d70c5b6

8 files changed

Lines changed: 252 additions & 0 deletions

File tree

28.4 KB
Loading
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React from 'react';
2+
import { StyleSheet } from 'react-native';
3+
import { fireEvent, render } from '@testing-library/react-native';
4+
import { useNavigation } from '@react-navigation/native';
5+
import { useSelector } from 'react-redux';
6+
import Routes from '../../../../../constants/navigation/Routes';
7+
import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../../constants/flags';
8+
import PredictWorldCupMainFeedBanner, {
9+
getPredictWorldCupBannerSource,
10+
} from './PredictWorldCupMainFeedBanner';
11+
import { PredictWorldCupMainFeedBannerSelectorsIDs } from './PredictWorldCupMainFeedBanner.testIds';
12+
13+
jest.mock('@react-navigation/native', () => ({
14+
useNavigation: jest.fn(),
15+
}));
16+
17+
jest.mock('react-redux', () => ({
18+
useSelector: jest.fn(),
19+
}));
20+
21+
jest.mock('react-native', () => {
22+
const actualReactNative = jest.requireActual('react-native');
23+
return {
24+
...actualReactNative,
25+
useWindowDimensions: jest.fn(() => ({
26+
width: 393,
27+
height: 852,
28+
scale: 3,
29+
fontScale: 1,
30+
})),
31+
};
32+
});
33+
34+
const mockUseNavigation = useNavigation as jest.Mock;
35+
const mockUseSelector = useSelector as jest.Mock;
36+
const mockNavigate = jest.fn();
37+
38+
const enabledConfig = {
39+
...DEFAULT_PREDICT_WORLD_CUP_FLAG,
40+
enabled: true,
41+
showMainFeedBanner: true,
42+
showWorldCupScreen: true,
43+
};
44+
45+
describe('PredictWorldCupMainFeedBanner', () => {
46+
beforeEach(() => {
47+
jest.clearAllMocks();
48+
mockUseNavigation.mockReturnValue({ navigate: mockNavigate });
49+
mockUseSelector.mockReturnValue(enabledConfig);
50+
});
51+
52+
it('renders when the banner and World Cup screen are enabled and fallback image exists', () => {
53+
const { getByTestId } = render(<PredictWorldCupMainFeedBanner />);
54+
55+
expect(
56+
getByTestId(PredictWorldCupMainFeedBannerSelectorsIDs.CONTAINER),
57+
).toBeOnTheScreen();
58+
expect(
59+
getByTestId(PredictWorldCupMainFeedBannerSelectorsIDs.IMAGE),
60+
).toBeOnTheScreen();
61+
});
62+
63+
it('uses the remote banner image URL when configured', () => {
64+
const bannerImageUrl = 'https://example.com/world-cup-banner.png';
65+
mockUseSelector.mockReturnValue({
66+
...enabledConfig,
67+
bannerImageUrl,
68+
});
69+
70+
const { getByTestId } = render(<PredictWorldCupMainFeedBanner />);
71+
const image = getByTestId(PredictWorldCupMainFeedBannerSelectorsIDs.IMAGE);
72+
73+
expect(image.props.source).toStrictEqual({ uri: bannerImageUrl });
74+
expect(StyleSheet.flatten(image.props.style).height).toBeGreaterThan(0);
75+
});
76+
77+
it('does not render when the main feed banner is disabled', () => {
78+
mockUseSelector.mockReturnValue({
79+
...enabledConfig,
80+
showMainFeedBanner: false,
81+
});
82+
83+
const { queryByTestId } = render(<PredictWorldCupMainFeedBanner />);
84+
85+
expect(
86+
queryByTestId(PredictWorldCupMainFeedBannerSelectorsIDs.CONTAINER),
87+
).toBeNull();
88+
});
89+
90+
it('does not render when the World Cup screen is disabled', () => {
91+
mockUseSelector.mockReturnValue({
92+
...enabledConfig,
93+
showWorldCupScreen: false,
94+
});
95+
96+
const { queryByTestId } = render(<PredictWorldCupMainFeedBanner />);
97+
98+
expect(
99+
queryByTestId(PredictWorldCupMainFeedBannerSelectorsIDs.CONTAINER),
100+
).toBeNull();
101+
});
102+
103+
it('does not render when there is no remote image URL or fallback image', () => {
104+
const { queryByTestId } = render(
105+
<PredictWorldCupMainFeedBanner fallbackImageSource={null} />,
106+
);
107+
108+
expect(
109+
queryByTestId(PredictWorldCupMainFeedBannerSelectorsIDs.CONTAINER),
110+
).toBeNull();
111+
});
112+
113+
it('navigates to the World Cup screen when pressed', () => {
114+
const { getByTestId } = render(<PredictWorldCupMainFeedBanner />);
115+
116+
fireEvent.press(
117+
getByTestId(PredictWorldCupMainFeedBannerSelectorsIDs.CONTAINER),
118+
);
119+
120+
expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.WORLD_CUP);
121+
});
122+
});
123+
124+
describe('getPredictWorldCupBannerSource', () => {
125+
it('returns a trimmed remote URI source before the fallback image source', () => {
126+
expect(
127+
getPredictWorldCupBannerSource(' https://example.com/banner.png ', 1),
128+
).toStrictEqual({ uri: 'https://example.com/banner.png' });
129+
});
130+
131+
it('returns the fallback image source when remote URL is missing', () => {
132+
expect(getPredictWorldCupBannerSource(undefined, 1)).toBe(1);
133+
});
134+
135+
it('returns undefined when remote URL and fallback are both missing', () => {
136+
expect(getPredictWorldCupBannerSource()).toBeUndefined();
137+
});
138+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const PredictWorldCupMainFeedBannerSelectorsIDs = {
2+
CONTAINER: 'predict-world-cup-main-feed-banner',
3+
IMAGE: 'predict-world-cup-main-feed-banner-image',
4+
} as const;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, { useCallback, useMemo } from 'react';
2+
import {
3+
Image,
4+
ImageSourcePropType,
5+
Pressable,
6+
useWindowDimensions,
7+
} from 'react-native';
8+
import { useNavigation } from '@react-navigation/native';
9+
import { useSelector } from 'react-redux';
10+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
11+
import Routes from '../../../../../constants/navigation/Routes';
12+
import { selectPredictWorldCupConfig } from '../../selectors/featureFlags';
13+
import type { PredictWorldCupConfig } from '../../types/flags';
14+
import { PredictWorldCupMainFeedBannerSelectorsIDs } from './PredictWorldCupMainFeedBanner.testIds';
15+
16+
import worldCupMainFeedBannerImage from '../../assets/world-cup-main-feed-banner.png';
17+
18+
const WORLD_CUP_BANNER_ASPECT_RATIO = 360 / 177;
19+
const WORLD_CUP_BANNER_HORIZONTAL_MARGIN = 16;
20+
const WORLD_CUP_BANNER_HORIZONTAL_MARGIN_TOTAL =
21+
WORLD_CUP_BANNER_HORIZONTAL_MARGIN * 2;
22+
23+
export const getPredictWorldCupBannerSource = (
24+
bannerImageUrl?: string,
25+
fallbackImageSource?: ImageSourcePropType,
26+
): ImageSourcePropType | undefined => {
27+
const trimmedBannerImageUrl = bannerImageUrl?.trim();
28+
29+
if (trimmedBannerImageUrl) {
30+
return { uri: trimmedBannerImageUrl };
31+
}
32+
33+
return fallbackImageSource;
34+
};
35+
36+
interface PredictWorldCupMainFeedBannerProps {
37+
fallbackImageSource?: ImageSourcePropType | null;
38+
}
39+
40+
const shouldRenderBanner = ({
41+
enabled,
42+
showMainFeedBanner,
43+
showWorldCupScreen,
44+
}: Pick<
45+
PredictWorldCupConfig,
46+
'enabled' | 'showMainFeedBanner' | 'showWorldCupScreen'
47+
>): boolean => enabled && showMainFeedBanner && showWorldCupScreen;
48+
49+
const PredictWorldCupMainFeedBanner: React.FC<
50+
PredictWorldCupMainFeedBannerProps
51+
> = ({ fallbackImageSource }) => {
52+
const tw = useTailwind();
53+
const { width: windowWidth } = useWindowDimensions();
54+
const navigation = useNavigation();
55+
const predictWorldCupConfig = useSelector(selectPredictWorldCupConfig);
56+
const bannerWidth = Math.max(
57+
windowWidth - WORLD_CUP_BANNER_HORIZONTAL_MARGIN_TOTAL,
58+
0,
59+
);
60+
const bannerHeight = bannerWidth / WORLD_CUP_BANNER_ASPECT_RATIO;
61+
62+
const resolvedFallbackImageSource =
63+
fallbackImageSource === undefined
64+
? worldCupMainFeedBannerImage
65+
: (fallbackImageSource ?? undefined);
66+
67+
const imageSource = useMemo(
68+
() =>
69+
shouldRenderBanner(predictWorldCupConfig)
70+
? getPredictWorldCupBannerSource(
71+
predictWorldCupConfig.bannerImageUrl,
72+
resolvedFallbackImageSource,
73+
)
74+
: undefined,
75+
[resolvedFallbackImageSource, predictWorldCupConfig],
76+
);
77+
78+
const handlePress = useCallback(() => {
79+
navigation.navigate(Routes.PREDICT.WORLD_CUP);
80+
}, [navigation]);
81+
82+
if (!imageSource) {
83+
return null;
84+
}
85+
86+
return (
87+
<Pressable
88+
accessibilityRole="button"
89+
onPress={handlePress}
90+
style={tw.style('mx-4 pb-3')}
91+
testID={PredictWorldCupMainFeedBannerSelectorsIDs.CONTAINER}
92+
>
93+
<Image
94+
source={imageSource}
95+
resizeMode="cover"
96+
testID={PredictWorldCupMainFeedBannerSelectorsIDs.IMAGE}
97+
style={tw.style('w-full rounded-xl', { height: bannerHeight })}
98+
/>
99+
</Pressable>
100+
);
101+
};
102+
103+
export default PredictWorldCupMainFeedBanner;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default } from './PredictWorldCupMainFeedBanner';
2+
export { getPredictWorldCupBannerSource } from './PredictWorldCupMainFeedBanner';
3+
export { PredictWorldCupMainFeedBannerSelectorsIDs } from './PredictWorldCupMainFeedBanner.testIds';

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export interface PredictNavigationParamList extends ParamListBase {
100100
Predict: undefined;
101101
PredictMarketList: PredictMarketListParams;
102102
PredictMarketDetails: PredictMarketDetailsParams;
103+
PredictWorldCup: undefined;
103104
PredictSellPreview: PredictSellPreviewParams;
104105
PredictBuyPreview: PredictBuyPreviewParams;
105106
PredictActivityDetail: PredictActivityDetailParams;

app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import PredictWithdrawUnavailableSheet, {
7171
} from '../../components/PredictWithdrawUnavailableSheet';
7272
import PredictOffline from '../../components/PredictOffline';
7373
import FeaturedCarousel from '../../components/FeaturedCarousel';
74+
import PredictWorldCupMainFeedBanner from '../../components/PredictWorldCupMainFeedBanner';
7475
import {
7576
selectPredictFeaturedCarouselEnabledFlag,
7677
selectPredictUpDownEnabledFlag,
@@ -208,6 +209,7 @@ const AnimatedHeader: React.FC<AnimatedHeaderProps> = ({
208209
<FeaturedCarousel />
209210
</Box>
210211
)}
212+
<PredictWorldCupMainFeedBanner />
211213
</Animated.View>
212214
<View
213215
ref={tabBarRef}

app/constants/navigation/Routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ const Routes = {
378378
MARKET_LIST: 'PredictMarketList',
379379
MARKET_DETAILS: 'PredictMarketDetails',
380380
ACTIVITY_DETAIL: 'PredictActivityDetail',
381+
WORLD_CUP: 'PredictWorldCup',
381382
MODALS: {
382383
ROOT: 'PredictModals',
383384
BUY_PREVIEW: 'PredictBuyPreview',

0 commit comments

Comments
 (0)