Skip to content

Commit ea1cbfe

Browse files
authored
feat: reward ways to earn for predict (MetaMask#22609)
## **Description** This PR adds a predict ways to earn cta and bottom sheet. Only if the required predict feature flag is turned on. ## **Changelog** CHANGELOG entry: rewards predict ways to earn cta ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/RWDS-787 ## **Screenshots/Recordings** ### **After** <img width="636" height="640" alt="image" src="https://github.com/user-attachments/assets/63cdf2a6-b005-49fe-b18f-7e4d9cdb1769" /> <img width="672" height="521" alt="image" src="https://github.com/user-attachments/assets/69d43c0b-09ee-4d45-acd1-a12a939ec8b4" /> ## **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. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > Adds a feature-flagged Predict earning option with bottom sheet CTA, navigation to market list, and metrics tracking, plus comprehensive tests and i18n strings. > > - **Rewards Overview — Ways to Earn**: > - **Predict entry (feature-flagged)**: Adds `WayToEarnType.PREDICT`, list item, bottom sheet content, and CTA handling in `WaysToEarn.tsx`. > - **Navigation**: Predict CTA navigates to `Routes.PREDICT.ROOT` → `MARKET_LIST`; list filtering honors `selectPredictEnabledFlag` alongside Card flag. > - **Metrics**: Tracks `REWARDS_PAGE_BUTTON_CLICKED` and `REWARDS_WAYS_TO_EARN_CTA_CLICKED` with `ways_to_earn_type` and `button_type`. > - **Tests** (`WaysToEarn.test.tsx`): > - Covers Predict visibility (flag on/off), modal rendering, CTA navigation, and metrics assertions. > - Extends existing tests (Swap/Perps/Referral/Card) with metrics checks and CTA flows; validates enum values and hook configuration. > - **Localization** (`locales/languages/en.json`): > - Adds Predict strings under `rewards.ways_to_earn.predict` (titles, descriptions, sheet, CTA). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8bfc213. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 1cda10b commit ea1cbfe

3 files changed

Lines changed: 233 additions & 5 deletions

File tree

app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@ import { ModalType } from '../../../../components/RewardsBottomSheetModal';
77
import { SwapBridgeNavigationLocation } from '../../../../../Bridge/hooks/useSwapBridgeNavigation';
88
import { selectIsFirstTimePerpsUser } from '../../../../../Perps/selectors/perpsController';
99
import { selectRewardsCardSpendFeatureFlags } from '../../../../../../../selectors/featureFlagController/rewards';
10+
import { selectPredictEnabledFlag } from '../../../../../Predict/selectors/featureFlags';
11+
import { MetaMetricsEvents } from '../../../../../../hooks/useMetrics';
12+
import { RewardsMetricsButtons } from '../../../../utils';
1013

1114
// Mock navigation
1215
const mockNavigate = jest.fn();
1316
const mockGoBack = jest.fn();
1417
const mockGoToSwaps = jest.fn();
18+
const mockTrackEvent = jest.fn();
19+
const mockCreateEventBuilder = jest.fn();
1520
let mockIsFirstTimePerpsUser = false;
1621
let mockIsCardSpendEnabled = false;
22+
let mockIsPredictEnabled = false;
1723

1824
jest.mock('@react-navigation/native', () => ({
1925
useNavigation: jest.fn(),
@@ -35,6 +41,18 @@ jest.mock('react-redux', () => ({
3541
useSelector: jest.fn(),
3642
}));
3743

44+
// Mock useMetrics hook
45+
jest.mock('../../../../../../hooks/useMetrics', () => ({
46+
useMetrics: jest.fn(() => ({
47+
trackEvent: mockTrackEvent,
48+
createEventBuilder: mockCreateEventBuilder,
49+
})),
50+
MetaMetricsEvents: {
51+
REWARDS_WAYS_TO_EARN_CTA_CLICKED: 'rewards_ways_to_earn_cta_clicked',
52+
REWARDS_PAGE_BUTTON_CLICKED: 'rewards_page_button_clicked',
53+
},
54+
}));
55+
3856
// Mock getNativeAssetForChainId
3957
jest.mock('@metamask/bridge-controller', () => ({
4058
getNativeAssetForChainId: jest.fn(() => ({
@@ -78,6 +96,15 @@ jest.mock('../../../../../../../../locales/i18n', () => ({
7896
'rewards.ways_to_earn.loyalty.sheet.description':
7997
'Add accounts with past swaps or bridges in MetaMask to earn loyalty bonuses. Each eligible account unlocks points in increments of 250, up to a total of 50,000. Bonuses appear shortly after you add an account.',
8098
'rewards.ways_to_earn.loyalty.sheet.cta_label': 'Add accounts',
99+
// Predict strings
100+
'rewards.ways_to_earn.predict.title': 'Prediction markets',
101+
'rewards.ways_to_earn.predict.description':
102+
'20 points per $10 prediction',
103+
'rewards.ways_to_earn.predict.sheet.title': 'Prediction markets',
104+
'rewards.ways_to_earn.predict.sheet.points': '20 points per $10',
105+
'rewards.ways_to_earn.predict.sheet.description':
106+
'Earn points on every $10 you trade.',
107+
'rewards.ways_to_earn.predict.sheet.cta_label': 'Browse markets',
81108
// Card strings
82109
'rewards.ways_to_earn.card.title': 'MetaMask Card',
83110
'rewards.ways_to_earn.card.description': '1 point per $1 spent',
@@ -138,12 +165,20 @@ describe('WaysToEarn', () => {
138165
jest.clearAllMocks();
139166
mockIsFirstTimePerpsUser = false;
140167
mockIsCardSpendEnabled = false;
168+
mockIsPredictEnabled = false;
141169

142170
mockUseNavigation.mockReturnValue({
143171
navigate: mockNavigate,
144172
goBack: mockGoBack,
145173
} as unknown as ReturnType<typeof useNavigation>);
146174

175+
// Mock createEventBuilder to return a builder object
176+
mockCreateEventBuilder.mockImplementation(() => ({
177+
addProperties: jest.fn().mockReturnThis(),
178+
addSensitiveProperties: jest.fn().mockReturnThis(),
179+
build: jest.fn().mockReturnValue({}),
180+
}));
181+
147182
// Assign useSelector implementation here so we can safely reference imported selectors
148183
const mockUseSelector = jest.requireMock('react-redux')
149184
.useSelector as jest.Mock;
@@ -154,6 +189,9 @@ describe('WaysToEarn', () => {
154189
if (selector === selectRewardsCardSpendFeatureFlags) {
155190
return mockIsCardSpendEnabled;
156191
}
192+
if (selector === selectPredictEnabledFlag) {
193+
return mockIsPredictEnabled;
194+
}
157195
return undefined;
158196
});
159197
});
@@ -175,6 +213,8 @@ describe('WaysToEarn', () => {
175213
expect(getByText('Perps')).toBeOnTheScreen();
176214
expect(getByText('Refer friends')).toBeOnTheScreen();
177215
expect(getByText('Loyalty bonus')).toBeOnTheScreen();
216+
// Predict hidden when flag disabled
217+
expect(queryByText('Prediction markets')).not.toBeOnTheScreen();
178218
// MM Card Spend hidden when flag disabled
179219
expect(queryByText('MetaMask Card')).not.toBeOnTheScreen();
180220
});
@@ -188,6 +228,7 @@ describe('WaysToEarn', () => {
188228
expect(getByText('10 points per $100')).toBeOnTheScreen();
189229
expect(getByText('10 points per 50 from friends')).toBeOnTheScreen();
190230
expect(getByText('Earn points from past trades')).toBeOnTheScreen();
231+
expect(queryByText('20 points per $10 prediction')).not.toBeOnTheScreen();
191232
expect(queryByText('1 point per $1 spent')).not.toBeOnTheScreen();
192233
});
193234

@@ -203,6 +244,10 @@ describe('WaysToEarn', () => {
203244
expect(mockNavigate).toHaveBeenCalledWith(
204245
Routes.MODAL.REWARDS_REFERRAL_BOTTOM_SHEET_MODAL,
205246
);
247+
expect(mockTrackEvent).toHaveBeenCalled();
248+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
249+
MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED,
250+
);
206251
});
207252

208253
it('opens modal for swap earning way when pressed', () => {
@@ -226,6 +271,10 @@ describe('WaysToEarn', () => {
226271
}),
227272
}),
228273
);
274+
expect(mockTrackEvent).toHaveBeenCalled();
275+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
276+
MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED,
277+
);
229278
});
230279

231280
it('opens modal for perps earning way when pressed', () => {
@@ -271,6 +320,10 @@ describe('WaysToEarn', () => {
271320
// Assert
272321
expect(mockGoBack).toHaveBeenCalled();
273322
expect(mockGoToSwaps).toHaveBeenCalled();
323+
expect(mockTrackEvent).toHaveBeenCalled();
324+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
325+
MetaMetricsEvents.REWARDS_WAYS_TO_EARN_CTA_CLICKED,
326+
);
274327
});
275328

276329
it('navigates to perps tutorial for first-time users', () => {
@@ -410,10 +463,86 @@ describe('WaysToEarn', () => {
410463
expect(WayToEarnType.PERPS).toBe('perps');
411464
expect(WayToEarnType.REFERRALS).toBe('referrals');
412465
expect(WayToEarnType.LOYALTY).toBe('loyalty');
466+
expect(WayToEarnType.PREDICT).toBe('predict');
413467
expect(WayToEarnType.CARD).toBe('card');
414468
});
415469
});
416470

471+
describe('Predict', () => {
472+
it('shows Predict earning way only when feature flag is enabled', () => {
473+
// Arrange
474+
const { queryByText, rerender } = render(<WaysToEarn />);
475+
476+
// Assert hidden by default
477+
expect(queryByText('Prediction markets')).not.toBeOnTheScreen();
478+
479+
// Enable flag
480+
mockIsPredictEnabled = true;
481+
rerender(<WaysToEarn />);
482+
483+
// Assert visible now
484+
expect(queryByText('Prediction markets')).toBeOnTheScreen();
485+
expect(queryByText('20 points per $10 prediction')).toBeOnTheScreen();
486+
});
487+
488+
it('opens modal for predict earning way when pressed', () => {
489+
// Arrange
490+
mockIsPredictEnabled = true;
491+
const { getByText } = render(<WaysToEarn />);
492+
const predictButton = getByText('Prediction markets');
493+
494+
// Act
495+
fireEvent.press(predictButton);
496+
497+
// Assert
498+
expect(mockNavigate).toHaveBeenCalledWith(
499+
Routes.MODAL.REWARDS_BOTTOM_SHEET_MODAL,
500+
expect.objectContaining({
501+
type: ModalType.Confirmation,
502+
showIcon: false,
503+
showCancelButton: false,
504+
confirmAction: expect.objectContaining({
505+
label: 'Browse markets',
506+
variant: 'Primary',
507+
}),
508+
}),
509+
);
510+
expect(mockTrackEvent).toHaveBeenCalled();
511+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
512+
MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED,
513+
);
514+
});
515+
516+
it('navigates to predict market list when predict CTA is pressed', () => {
517+
// Arrange
518+
mockIsPredictEnabled = true;
519+
const { getByText } = render(<WaysToEarn />);
520+
const predictButton = getByText('Prediction markets');
521+
522+
// Act
523+
fireEvent.press(predictButton);
524+
525+
// Get the onPress handler from the modal navigation call
526+
const modalCall = mockNavigate.mock.calls.find(
527+
(call) => call[0] === Routes.MODAL.REWARDS_BOTTOM_SHEET_MODAL,
528+
);
529+
const confirmAction = modalCall?.[1]?.confirmAction;
530+
531+
// Execute the CTA action
532+
confirmAction?.onPress();
533+
534+
// Assert
535+
expect(mockGoBack).toHaveBeenCalled();
536+
expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
537+
screen: Routes.PREDICT.MARKET_LIST,
538+
});
539+
expect(mockTrackEvent).toHaveBeenCalled();
540+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
541+
MetaMetricsEvents.REWARDS_WAYS_TO_EARN_CTA_CLICKED,
542+
);
543+
});
544+
});
545+
417546
describe('Card spend', () => {
418547
it('shows Card earning way only when feature flag is enabled', () => {
419548
// Arrange
@@ -471,4 +600,59 @@ describe('WaysToEarn', () => {
471600
});
472601
});
473602
});
603+
604+
describe('Metrics tracking', () => {
605+
it('tracks button click event with correct properties when earning way is pressed', () => {
606+
// Arrange
607+
const { getByText } = render(<WaysToEarn />);
608+
const swapButton = getByText('Swap');
609+
610+
// Act
611+
fireEvent.press(swapButton);
612+
613+
// Assert
614+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
615+
MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED,
616+
);
617+
const builder = mockCreateEventBuilder.mock.results[0].value;
618+
expect(builder.addProperties).toHaveBeenCalledWith({
619+
button_type: RewardsMetricsButtons.WAYS_TO_EARN,
620+
ways_to_earn_type: WayToEarnType.SWAPS,
621+
});
622+
expect(mockTrackEvent).toHaveBeenCalled();
623+
});
624+
625+
it('tracks CTA click event with correct properties when CTA is pressed', () => {
626+
// Arrange
627+
const { getByText } = render(<WaysToEarn />);
628+
const swapButton = getByText('Swap');
629+
630+
// Act
631+
fireEvent.press(swapButton);
632+
633+
// Get the onPress handler from the modal navigation call
634+
const modalCall = mockNavigate.mock.calls.find(
635+
(call) => call[0] === Routes.MODAL.REWARDS_BOTTOM_SHEET_MODAL,
636+
);
637+
const confirmAction = modalCall?.[1]?.confirmAction;
638+
639+
// Clear previous calls to track only CTA event
640+
mockCreateEventBuilder.mockClear();
641+
mockTrackEvent.mockClear();
642+
643+
// Execute the CTA action
644+
confirmAction?.onPress();
645+
646+
// Assert
647+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
648+
MetaMetricsEvents.REWARDS_WAYS_TO_EARN_CTA_CLICKED,
649+
);
650+
const ctaBuilder = mockCreateEventBuilder.mock.results[0]?.value;
651+
expect(ctaBuilder).toBeTruthy();
652+
expect(ctaBuilder.addProperties).toHaveBeenCalledWith({
653+
ways_to_earn_type: WayToEarnType.SWAPS,
654+
});
655+
expect(mockTrackEvent).toHaveBeenCalled();
656+
});
657+
});
474658
});

app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { useSelector } from 'react-redux';
2929
import { selectIsFirstTimePerpsUser } from '../../../../../Perps/selectors/perpsController';
3030
import { selectRewardsCardSpendFeatureFlags } from '../../../../../../../selectors/featureFlagController/rewards';
31+
import { selectPredictEnabledFlag } from '../../../../../Predict/selectors/featureFlags';
3132
import {
3233
MetaMetricsEvents,
3334
useMetrics,
@@ -39,6 +40,7 @@ export enum WayToEarnType {
3940
PERPS = 'perps',
4041
REFERRALS = 'referrals',
4142
LOYALTY = 'loyalty',
43+
PREDICT = 'predict',
4244
CARD = 'card',
4345
}
4446

@@ -62,6 +64,12 @@ const waysToEarn: WayToEarn[] = [
6264
description: strings('rewards.ways_to_earn.perps.description'),
6365
icon: IconName.Candlestick,
6466
},
67+
{
68+
type: WayToEarnType.PREDICT,
69+
title: strings('rewards.ways_to_earn.predict.title'),
70+
description: strings('rewards.ways_to_earn.predict.description'),
71+
icon: IconName.Speedometer,
72+
},
6573
{
6674
type: WayToEarnType.REFERRALS,
6775
title: strings('rewards.ways_to_earn.referrals.title'),
@@ -158,6 +166,21 @@ const getBottomSheetData = (type: WayToEarnType) => {
158166
),
159167
ctaLabel: strings('rewards.ways_to_earn.loyalty.sheet.cta_label'),
160168
};
169+
case WayToEarnType.PREDICT:
170+
return {
171+
title: (
172+
<WaysToEarnSheetTitle
173+
title={strings('rewards.ways_to_earn.predict.sheet.title')}
174+
points={strings('rewards.ways_to_earn.predict.sheet.points')}
175+
/>
176+
),
177+
description: (
178+
<Text variant={TextVariant.BodyMd} twClassName="text-alternative">
179+
{strings('rewards.ways_to_earn.predict.sheet.description')}
180+
</Text>
181+
),
182+
ctaLabel: strings('rewards.ways_to_earn.predict.sheet.cta_label'),
183+
};
161184
case WayToEarnType.CARD:
162185
return {
163186
title: (
@@ -182,6 +205,7 @@ export const WaysToEarn = () => {
182205
const navigation = useNavigation();
183206
const isFirstTimePerpsUser = useSelector(selectIsFirstTimePerpsUser);
184207
const isCardSpendEnabled = useSelector(selectRewardsCardSpendFeatureFlags);
208+
const isPredictEnabled = useSelector(selectPredictEnabledFlag);
185209
const { trackEvent, createEventBuilder } = useMetrics();
186210

187211
// Use the swap/bridge navigation hook
@@ -219,6 +243,11 @@ export const WaysToEarn = () => {
219243
case WayToEarnType.LOYALTY:
220244
navigation.navigate(Routes.REWARDS_SETTINGS_VIEW);
221245
break;
246+
case WayToEarnType.PREDICT:
247+
navigation.navigate(Routes.PREDICT.ROOT, {
248+
screen: Routes.PREDICT.MARKET_LIST,
249+
});
250+
break;
222251
case WayToEarnType.CARD:
223252
navigation.navigate(Routes.CARD.ROOT);
224253
break;
@@ -238,6 +267,7 @@ export const WaysToEarn = () => {
238267
case WayToEarnType.SWAPS:
239268
case WayToEarnType.LOYALTY:
240269
case WayToEarnType.PERPS:
270+
case WayToEarnType.PREDICT:
241271
case WayToEarnType.CARD: {
242272
const { title, description, ctaLabel } = getBottomSheetData(
243273
wayToEarn.type,
@@ -274,11 +304,15 @@ export const WaysToEarn = () => {
274304
<Box twClassName="rounded-xl bg-muted">
275305
<FlatList
276306
horizontal={false}
277-
data={
278-
isCardSpendEnabled
279-
? waysToEarn
280-
: waysToEarn.filter((way) => way.type !== WayToEarnType.CARD)
281-
}
307+
data={waysToEarn.filter((wte) => {
308+
if (wte.type === WayToEarnType.CARD && !isCardSpendEnabled) {
309+
return false;
310+
}
311+
if (wte.type === WayToEarnType.PREDICT && !isPredictEnabled) {
312+
return false;
313+
}
314+
return true;
315+
})}
282316
keyExtractor={(wayToEarn) => wayToEarn.title}
283317
ItemSeparatorComponent={Separator}
284318
scrollEnabled={false}

locales/languages/en.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6752,6 +6752,16 @@
67526752
"cta_label": "Add accounts"
67536753
}
67546754
},
6755+
"predict": {
6756+
"title": "Prediction markets",
6757+
"description": "20 points per $10 prediction",
6758+
"sheet": {
6759+
"title": "Prediction markets",
6760+
"points": "20 points per $10",
6761+
"description": "Earn points on every $10 you trade.",
6762+
"cta_label": "Browse markets"
6763+
}
6764+
},
67556765
"card": {
67566766
"title": "MetaMask Card",
67576767
"description": "1 point per $1 spent",

0 commit comments

Comments
 (0)