Skip to content

Commit a4522a3

Browse files
authored
feat(rewards): update campaigns feature flag behavior on dashboard (MetaMask#27803)
## **Description** Updates how the campaigns feature flag controls behavior on the Rewards Dashboard: - **Dashboard always renders** `CampaignsPreview` and `EarnRewardsPreview` components regardless of feature flag state - **Feature flag logic moved to `useRewardCampaigns` hook** - when disabled: - Shows only upcoming campaigns (filtered by start date) - Falls back to previous season campaigns if no upcoming campaigns exist - **Campaign tiles become non-interactive when disabled** - users cannot navigate to campaign details - **Adds `fetchUpcomingOrPreviousSeasonCampaigns`** method to RewardsController for the fallback logic ### Key Changes | Before | After | |--------|-------| | Feature flag hides entire campaigns section | Always shows campaigns section | | N/A | When disabled: shows upcoming campaigns only | | N/A | When disabled: falls back to previous season | | All tiles navigable | Non-interactive tiles when flag disabled | ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** _N/A_ ## **Manual testing steps** 1. Enable campaigns feature flag → Full campaigns functionality 2. Disable campaigns feature flag → Only upcoming campaigns shown, tiles non-interactive 3. Disable flag with no upcoming campaigns → Previous season campaigns displayed ## **Screenshots/Recordings** - Feature flag off: only show upcoming campaign as featured <img width="983" height="1958" alt="Screenshot from 2026-03-23 14-16-10" src="https://github.com/user-attachments/assets/97c27a9d-0bfd-4fe7-b058-24d59fbf2652" /> - Feature flag off: tapping section or featured tile on rewards dashboard leads to campaign overview page. Only previous season tile is tappable <img width="983" height="1958" alt="Screenshot from 2026-03-23 14-16-17" src="https://github.com/user-attachments/assets/954d9a27-0a73-47c7-8c0d-029adc4a59eb" /> - Feature flag off: what would happen if no upcoming campaigns are found, we can fallback to the previous season tile, which would take you to the previous season summary if it/the section is tapped. <img width="983" height="1958" alt="Screenshot from 2026-03-23 14-17-30" src="https://github.com/user-attachments/assets/6618359a-f2cd-470b-b17a-9a00319c7f68" /> - If feature flag is on, then we will not filter on upcoming and allow users to actually navigate to the campaign details page via campaign tiles. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) - [x] I've completed the PR template - [x] I've included tests - [x] I've documented any changes (if applicable) ## **Pre-merge reviewer checklist** - [ ] I've verified the changes follow established patterns - [ ] I've verified the test coverage is adequate Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate UI/behavior change around the campaigns feature flag that alters what content is shown and which navigation paths are enabled; risk is mainly regressions in campaign visibility and tap behavior when the flag toggles. > > **Overview** > **Rewards dashboard now always renders** `CampaignsPreview` and `EarnRewardsPreview`, removing the prior conditional/previous-season UI paths (including the referral header action). > > **Campaign feature flag behavior is pushed down into campaigns components/hooks:** `useRewardCampaigns` always fetches campaigns when subscribed but filters results to *upcoming-only* when the flag is off, while `CampaignTile` adds `isInteractive`/`onPress` to disable deep-link navigation and allow alternate navigation. `CampaignsPreview` adds a no-campaigns fallback that shows `PreviousSeasonTile` and navigates to `Routes.PREVIOUS_SEASON_VIEW`. > > Tests were updated accordingly across `RewardsDashboard`, `CampaignsPreview`, `CampaignTile`, `useRewardCampaigns`, and `RewardsController` to reflect the new fetch/filtering and interactivity rules. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9ca3bc0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6e0f698 commit a4522a3

11 files changed

Lines changed: 468 additions & 1583 deletions

File tree

app/components/UI/Rewards/Views/RewardsDashboard.test.tsx

Lines changed: 171 additions & 1451 deletions
Large diffs are not rendered by default.

app/components/UI/Rewards/Views/RewardsDashboard.tsx

Lines changed: 3 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import React, {
2-
useEffect,
3-
useCallback,
4-
useMemo,
5-
useRef,
6-
useState,
7-
} from 'react';
1+
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
82
import { useFocusEffect, useNavigation } from '@react-navigation/native';
93
import { Box, IconName } from '@metamask/design-system-react-native';
104
import { useTailwind } from '@metamask/design-system-twrnc-preset';
@@ -19,28 +13,18 @@ import {
1913
selectActiveTab,
2014
selectHideUnlinkedAccountsBanner,
2115
selectHideCurrentAccountNotOptedInBannerArray,
22-
selectSeasonId,
23-
selectOptinAllowedForGeo,
2416
} from '../../../../reducers/rewards/selectors';
2517
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
26-
import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
2718
import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary';
2819
import {
2920
useRewardDashboardModals,
3021
RewardsDashboardModalType,
3122
} from '../hooks/useRewardDashboardModals';
3223
import { useBulkLinkState } from '../hooks/useBulkLinkState';
33-
import MusdCalculatorTab from '../components/Tabs/MusdCalculatorTab/MusdCalculatorTab';
34-
import { TabsList } from '../../../../component-library/components-temp/Tabs';
35-
import {
36-
TabsListRef,
37-
TabViewProps,
38-
} from '../../../../component-library/components-temp/Tabs/TabsList/TabsList.types';
3924
import Toast from '../../../../component-library/components/Toast';
4025
import { ToastRef } from '../../../../component-library/components/Toast/Toast.types';
4126
import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics';
4227
import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController';
43-
import PreviousSeasonSummary from '../components/PreviousSeason/PreviousSeasonSummary';
4428
import CampaignsPreview from '../components/Campaigns/CampaignsPreview';
4529
import EarnRewardsPreview from '../components/EarnRewards/EarnRewardsPreview';
4630

@@ -55,9 +39,6 @@ const RewardsDashboard: React.FC = () => {
5539
const hideUnlinkedAccountsBanner = useSelector(
5640
selectHideUnlinkedAccountsBanner,
5741
);
58-
const seasonId = useSelector(selectSeasonId);
59-
const optinAllowedForGeo = useSelector(selectOptinAllowedForGeo);
60-
const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag);
6142
const hideCurrentAccountNotOptedInBannerMap = useSelector(
6243
selectHideCurrentAccountNotOptedInBannerArray,
6344
);
@@ -73,11 +54,6 @@ const RewardsDashboard: React.FC = () => {
7354
return false;
7455
}, [selectedAccountGroup?.id, hideCurrentAccountNotOptedInBannerMap]);
7556

76-
const [showPreviousSeasonSummary, setShowPreviousSeasonSummary] = useState<
77-
boolean | null
78-
>(null);
79-
const tabsListRef = useRef<TabsListRef>(null);
80-
8157
// Use the reward dashboard modals hook
8258
const {
8359
showUnlinkedAccountsModal,
@@ -127,26 +103,11 @@ const RewardsDashboard: React.FC = () => {
127103
}, [wasInterrupted, isRunning, resumeBulkLink]),
128104
);
129105

130-
// Evaluate showPreviousSeasonSummary when screen comes into focus
131-
useFocusEffect(
132-
useCallback(() => {
133-
const shouldShow = Boolean(seasonId && !isCampaignsEnabled);
134-
setShowPreviousSeasonSummary(shouldShow);
135-
}, [seasonId, isCampaignsEnabled]),
136-
);
137-
138106
// Auto-trigger dashboard modals based on account/rewards state (session-aware)
139107
// This effect runs whenever key dependencies change and determines which informational
140108
// modal should be shown to guide the user. Each modal type is only shown once per app session.
141109
useFocusEffect(
142110
useCallback(() => {
143-
if (
144-
!seasonId ||
145-
showPreviousSeasonSummary === null ||
146-
showPreviousSeasonSummary
147-
)
148-
return;
149-
150111
if (
151112
(totalOptedInAccountsSelectedGroup === 0 ||
152113
currentAccountGroupPartiallySupported === false) &&
@@ -182,8 +143,6 @@ const RewardsDashboard: React.FC = () => {
182143
}
183144
}
184145
}, [
185-
seasonId,
186-
showPreviousSeasonSummary,
187146
totalOptedInAccountsSelectedGroup,
188147
currentAccountGroupPartiallySupported,
189148
hideCurrentAccountNotOptedInBanner,
@@ -232,51 +191,11 @@ const RewardsDashboard: React.FC = () => {
232191
disabled: !subscriptionId,
233192
testID: REWARDS_VIEW_SELECTORS.SETTINGS_BUTTON,
234193
},
235-
...(showPreviousSeasonSummary === false
236-
? [
237-
{
238-
iconName: IconName.UserCircleAdd,
239-
onPress: () =>
240-
navigation.navigate(Routes.REFERRAL_REWARDS_VIEW),
241-
disabled: !subscriptionId,
242-
testID: REWARDS_VIEW_SELECTORS.REFERRAL_BUTTON,
243-
},
244-
]
245-
: []),
246194
]}
247195
/>
248196
<Box twClassName="flex-1 gap-4">
249-
{isCampaignsEnabled && <CampaignsPreview />}
250-
{isCampaignsEnabled && <EarnRewardsPreview />}
251-
{showPreviousSeasonSummary &&
252-
(optinAllowedForGeo ? (
253-
<TabsList
254-
ref={tabsListRef}
255-
initialActiveIndex={0}
256-
testID={REWARDS_VIEW_SELECTORS.TAB_CONTROL}
257-
tabsBarProps={{ twClassName: 'px-4' }}
258-
tabsListContentTwClassName="px-0"
259-
>
260-
<Box
261-
key="musd"
262-
{...({ tabLabel: 'mUSD' } as TabViewProps)}
263-
twClassName="flex-1"
264-
>
265-
<MusdCalculatorTab />
266-
</Box>
267-
<Box
268-
key="previous-season"
269-
{...({
270-
tabLabel: strings('rewards.season_1'),
271-
} as TabViewProps)}
272-
twClassName="flex-1"
273-
>
274-
<PreviousSeasonSummary />
275-
</Box>
276-
</TabsList>
277-
) : (
278-
<PreviousSeasonSummary />
279-
))}
197+
<CampaignsPreview />
198+
<EarnRewardsPreview />
280199
</Box>
281200
</SafeAreaView>
282201
<Toast ref={toastRef} />

app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,5 +335,46 @@ describe('CampaignTile', () => {
335335
campaignId: 'camp-42',
336336
});
337337
});
338+
339+
it('calls custom onPress handler instead of navigating when provided', () => {
340+
const campaign = createTestCampaign({ id: 'camp-custom' });
341+
const mockOnPress = jest.fn();
342+
343+
const { getByTestId } = render(
344+
<CampaignTile campaign={campaign} onPress={mockOnPress} />,
345+
);
346+
fireEvent.press(getByTestId('campaign-tile-camp-custom'));
347+
348+
expect(mockOnPress).toHaveBeenCalledTimes(1);
349+
expect(mockNavigate).not.toHaveBeenCalled();
350+
});
351+
352+
it('does not navigate when isInteractive is false', () => {
353+
const campaign = createTestCampaign({ id: 'camp-disabled' });
354+
355+
const { getByTestId } = render(
356+
<CampaignTile campaign={campaign} isInteractive={false} />,
357+
);
358+
fireEvent.press(getByTestId('campaign-tile-camp-disabled'));
359+
360+
expect(mockNavigate).not.toHaveBeenCalled();
361+
});
362+
363+
it('does not call onPress when isInteractive is false', () => {
364+
const campaign = createTestCampaign({ id: 'camp-both' });
365+
const mockOnPress = jest.fn();
366+
367+
const { getByTestId } = render(
368+
<CampaignTile
369+
campaign={campaign}
370+
isInteractive={false}
371+
onPress={mockOnPress}
372+
/>,
373+
);
374+
fireEvent.press(getByTestId('campaign-tile-camp-both'));
375+
376+
expect(mockOnPress).not.toHaveBeenCalled();
377+
expect(mockNavigate).not.toHaveBeenCalled();
378+
});
338379
});
339380
});

app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,30 @@ import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipa
2626

2727
interface CampaignTileProps {
2828
campaign: CampaignDto;
29+
/**
30+
* Whether the tile is interactive (pressable). Defaults to true.
31+
* When false, the tile is displayed but cannot be tapped.
32+
*/
33+
isInteractive?: boolean;
34+
/**
35+
* Custom press handler. If provided, this is called instead of the default
36+
* navigation to campaign details. Only used when isInteractive is true.
37+
*/
38+
onPress?: () => void;
2939
}
3040

3141
/**
3242
* CampaignTile displays campaign information with status.
33-
* Tapping navigates to the campaign details screen.
43+
* Tapping behavior can be customized via props:
44+
* - Default: navigates to campaign details screen
45+
* - With onPress: executes custom handler
46+
* - With isInteractive=false: tile is not pressable
3447
*/
35-
const CampaignTile: React.FC<CampaignTileProps> = ({ campaign }) => {
48+
const CampaignTile: React.FC<CampaignTileProps> = ({
49+
campaign,
50+
isInteractive = true,
51+
onPress,
52+
}) => {
3653
const tw = useTailwind();
3754
const colorScheme = useColorScheme();
3855
const navigation = useNavigation();
@@ -58,16 +75,23 @@ const CampaignTile: React.FC<CampaignTileProps> = ({ campaign }) => {
5875
: campaign.details?.image?.lightModeUrl;
5976

6077
const handlePress = () => {
61-
navigation.navigate(Routes.CAMPAIGN_DETAILS, { campaignId: campaign.id });
78+
if (!isInteractive) return;
79+
80+
if (onPress) {
81+
onPress();
82+
} else {
83+
navigation.navigate(Routes.CAMPAIGN_DETAILS, { campaignId: campaign.id });
84+
}
6285
};
6386

6487
return (
6588
<Pressable
6689
onPress={handlePress}
90+
disabled={!isInteractive}
6791
style={({ pressed }) =>
6892
tw.style(
6993
'rounded-xl overflow-hidden h-50 bg-muted',
70-
pressed && 'opacity-70',
94+
pressed && isInteractive && 'opacity-70',
7195
)
7296
}
7397
testID={`campaign-tile-${campaign.id}`}

app/components/UI/Rewards/components/Campaigns/CampaignsGroup.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React from 'react';
2+
import { useSelector } from 'react-redux';
23
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
34
import CampaignTile from './CampaignTile';
45
import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types';
56
import PreviousSeasonTile from '../PreviousSeason/PreviousSeasonTile';
67
import { selectSeasonName } from '../../../../../reducers/rewards/selectors';
7-
import { useSelector } from 'react-redux';
8+
import { selectCampaignsRewardsEnabledFlag } from '../../../../../selectors/featureFlagController/rewards';
89

910
interface CampaignsGroupProps {
1011
title: string;
@@ -23,6 +24,7 @@ const CampaignsGroup: React.FC<CampaignsGroupProps> = ({
2324
displayPreviousSeason = false,
2425
}) => {
2526
const seasonName = useSelector(selectSeasonName);
27+
const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag);
2628
const showPreviousSeason = displayPreviousSeason && !!seasonName;
2729

2830
if (campaigns.length === 0 && !showPreviousSeason) {
@@ -35,7 +37,11 @@ const CampaignsGroup: React.FC<CampaignsGroupProps> = ({
3537
{title}
3638
</Text>
3739
{campaigns.map((campaign) => (
38-
<CampaignTile key={campaign.id} campaign={campaign} />
40+
<CampaignTile
41+
key={campaign.id}
42+
campaign={campaign}
43+
isInteractive={isCampaignsEnabled}
44+
/>
3945
))}
4046
{showPreviousSeason && <PreviousSeasonTile />}
4147
</Box>

app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ jest.mock('@react-navigation/native', () => ({
1414
useNavigation: () => ({ navigate: mockNavigate }),
1515
}));
1616

17+
jest.mock('../../../../../selectors/featureFlagController/rewards', () => ({
18+
selectCampaignsRewardsEnabledFlag: jest.fn(() => true),
19+
}));
20+
21+
jest.mock('react-redux', () => ({
22+
...jest.requireActual('react-redux'),
23+
useSelector: (selector: (state: unknown) => unknown) => selector({}),
24+
}));
25+
1726
jest.mock('@metamask/design-system-react-native', () => {
1827
const actual = jest.requireActual('@metamask/design-system-react-native');
1928
return { ...actual };
@@ -42,6 +51,20 @@ jest.mock('./CampaignTile', () => {
4251
};
4352
});
4453

54+
jest.mock('../PreviousSeason/PreviousSeasonTile', () => {
55+
const ReactActual = jest.requireActual('react');
56+
const { Text } = jest.requireActual('react-native');
57+
return {
58+
__esModule: true,
59+
default: () =>
60+
ReactActual.createElement(
61+
Text,
62+
{ testID: 'previous-season-tile' },
63+
'Previous Season Tile',
64+
),
65+
};
66+
});
67+
4568
jest.mock('../../../../../../locales/i18n', () => ({
4669
strings: (key: string) => {
4770
const translations: Record<string, string> = {
@@ -82,10 +105,27 @@ describe('CampaignsPreview', () => {
82105
mockUseRewardCampaigns.mockReturnValue(mockHookDefaults);
83106
});
84107

85-
it('returns null when there are no campaigns in any category', () => {
86-
const { queryByTestId } = render(<CampaignsPreview />);
108+
it('renders PreviousSeasonTile when there are no campaigns in any category', () => {
109+
const { getByTestId } = render(<CampaignsPreview />);
110+
111+
expect(
112+
getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW),
113+
).toBeOnTheScreen();
114+
expect(getByTestId('previous-season-tile')).toBeOnTheScreen();
115+
});
116+
117+
it('renders PreviousSeasonTile when there is an error and no campaigns', () => {
118+
mockUseRewardCampaigns.mockReturnValue({
119+
...mockHookDefaults,
120+
hasError: true,
121+
});
122+
123+
const { getByTestId } = render(<CampaignsPreview />);
87124

88-
expect(queryByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW)).toBeNull();
125+
expect(
126+
getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW),
127+
).toBeOnTheScreen();
128+
expect(getByTestId('previous-season-tile')).toBeOnTheScreen();
89129
});
90130

91131
it('renders the section title when an active campaign exists', () => {
@@ -248,4 +288,13 @@ describe('CampaignsPreview', () => {
248288

249289
expect(mockNavigate).toHaveBeenCalledWith(Routes.CAMPAIGNS_VIEW);
250290
});
291+
292+
it('navigates to previous season view when title header is pressed and no campaigns exist', () => {
293+
mockUseRewardCampaigns.mockReturnValue(mockHookDefaults);
294+
295+
const { getByText } = render(<CampaignsPreview />);
296+
fireEvent.press(getByText('Campaigns'));
297+
298+
expect(mockNavigate).toHaveBeenCalledWith(Routes.PREVIOUS_SEASON_VIEW);
299+
});
251300
});

0 commit comments

Comments
 (0)