Skip to content

Commit 8592d48

Browse files
authored
chore(rewards): add vip splash screen (MetaMask#30818)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** https://consensyssoftware.atlassian.net/browse/RWDS-1341 https://consensyssoftware.atlassian.net/browse/RWDS-1337 Add splash screen for vip users Replace HyperTracker logo <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **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** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-05-29 at 13 52 23" src="https://github.com/user-attachments/assets/e0f8e80a-f3d0-4c03-ad3f-084226e0d335" /> https://github.com/user-attachments/assets/be5989e7-14ac-4795-836b-411ce2f49dff <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-05-29 at 13 42 56" src="https://github.com/user-attachments/assets/9dfe1543-19fb-4cbf-874c-f5282b246aba" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-05-29 at 13 43 00" src="https://github.com/user-attachments/assets/98b6816b-4862-4302-b7e1-91f848c5779a" /> <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] 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 - [x] 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** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [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] > **Low Risk** > Rewards UI and local persisted invite flags only; no auth, payments, or security-sensitive logic. > > **Overview** > Adds a **first-time VIP invite splash** for eligible rewards users: new `RewardsVipSplashView` / `VipSplashScreen`, route `REWARDS_VIP_SPLASH_VIEW`, and copy in `en.json`. **Per-subscription** acceptance is stored in Redux as `vipSplashAccepted`, updated via `acceptVipInvite` (persisted on rehydrate and when switching candidate subscription). > > The **dashboard VIP button** now opens the splash until the invite is accepted, then goes straight to `REWARDS_VIP_VIEW`. **Accept** on the splash replaces into the VIP screen, where `acceptVipInvite` runs on mount if not already accepted; **Not now** goes back or replaces to the dashboard. The splash auto-redirects if VIP is disabled or the invite was already accepted. > > Also swaps the Perps campaign leaderboard **HyperTracker** attribution from tappable text to a **logo** (`hypertracker.svg`) with an accessibility label, plus matching test updates. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e5d7287. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 067dcbd commit 8592d48

21 files changed

Lines changed: 789 additions & 31 deletions

app/actions/rewards/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
setGeoRewardsMetadataLoading,
1313
setActiveBoosts,
1414
setActiveBoostsLoading,
15+
acceptVipInvite,
1516
} from '../../reducers/rewards';
1617

1718
export type { RewardsState } from '../../reducers/rewards';

app/components/UI/Rewards/RewardsNavigator.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ jest.mock('./Views/RewardsVipView', () => {
7878
};
7979
});
8080

81+
jest.mock('./Views/RewardsVipSplashView', () => {
82+
const ReactActual = jest.requireActual('react');
83+
const { View, Text } = jest.requireActual('react-native');
84+
return function MockRewardsVipSplashView() {
85+
return ReactActual.createElement(
86+
View,
87+
{ testID: 'rewards-vip-splash-view' },
88+
ReactActual.createElement(Text, null, 'Rewards VIP Splash View'),
89+
);
90+
};
91+
});
92+
8193
jest.mock('./Views/CampaignTourStepView', () => {
8294
const ReactActual = jest.requireActual('react');
8395
const { View, Text } = jest.requireActual('react-native');

app/components/UI/Rewards/RewardsNavigator.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import OnboardingNavigator from './OnboardingNavigator';
99
import RewardsDashboard from './Views/RewardsDashboard';
1010
import ReferralRewardsView from './Views/RewardsReferralView';
1111
import RewardsSettingsView from './Views/RewardsSettingsView';
12+
import RewardsVipSplashView from './Views/RewardsVipSplashView';
1213
import RewardsVipView from './Views/RewardsVipView';
1314
import RewardsVipTiersView from './Views/RewardsVipTiersView';
1415
import CampaignsView from './Views/CampaignsView';
@@ -225,6 +226,17 @@ const RewardsNavigator: React.FC = () => {
225226
component={RewardsSettingsView}
226227
options={{ headerShown: false }}
227228
/>
229+
<Stack.Screen
230+
name={Routes.REWARDS_VIP_SPLASH_VIEW}
231+
component={RewardsVipSplashView}
232+
options={{
233+
headerShown: false,
234+
...TransitionPresets.SlideFromRightIOS,
235+
cardStyle: {
236+
backgroundColor: darkTheme.colors.background.default,
237+
},
238+
}}
239+
/>
228240
<Stack.Screen
229241
name={Routes.REWARDS_VIP_VIEW}
230242
component={RewardsVipView}

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ jest.mock('@react-navigation/native', () => {
3737
// Mock selectors
3838
jest.mock('../../../../reducers/rewards/selectors', () => ({
3939
selectActiveTab: jest.fn(),
40+
selectHasAcceptedVipInvite: jest.fn(),
4041
selectHideCurrentAccountNotOptedInBannerArray: jest.fn(),
4142
selectHideUnlinkedAccountsBanner: jest.fn(),
4243
}));
@@ -55,6 +56,7 @@ jest.mock(
5556

5657
import {
5758
selectActiveTab,
59+
selectHasAcceptedVipInvite,
5860
selectHideUnlinkedAccountsBanner,
5961
selectHideCurrentAccountNotOptedInBannerArray,
6062
} from '../../../../reducers/rewards/selectors';
@@ -67,6 +69,11 @@ import { selectSelectedAccountGroup } from '../../../../selectors/multichainAcco
6769
const mockSelectActiveTab = selectActiveTab as jest.MockedFunction<
6870
typeof selectActiveTab
6971
>;
72+
const mockSelectHasAcceptedVipInvite =
73+
selectHasAcceptedVipInvite as jest.MockedFunction<
74+
typeof selectHasAcceptedVipInvite
75+
>;
76+
const mockHasAcceptedVipInviteSelector = jest.fn();
7077
const mockSelectRewardsSubscriptionId =
7178
selectRewardsSubscriptionId as jest.MockedFunction<
7279
typeof selectRewardsSubscriptionId
@@ -327,6 +334,10 @@ describe('RewardsDashboard', () => {
327334
mockSelectSelectedAccountGroup.mockReturnValue(
328335
defaultSelectorValues.selectedAccountGroup,
329336
);
337+
mockSelectHasAcceptedVipInvite.mockReturnValue(
338+
mockHasAcceptedVipInviteSelector,
339+
);
340+
mockHasAcceptedVipInviteSelector.mockReturnValue(false);
330341

331342
// Setup hook mocks
332343
mockUseRewardOptinSummary.mockReturnValue(
@@ -352,6 +363,7 @@ describe('RewardsDashboard', () => {
352363
return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray;
353364
if (selector === selectSelectedAccountGroup)
354365
return defaultSelectorValues.selectedAccountGroup;
366+
if (selector === mockHasAcceptedVipInviteSelector) return false;
355367
return undefined;
356368
});
357369
});
@@ -467,6 +479,7 @@ describe('RewardsDashboard', () => {
467479
return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray;
468480
if (selector === selectSelectedAccountGroup)
469481
return defaultSelectorValues.selectedAccountGroup;
482+
if (selector === mockHasAcceptedVipInviteSelector) return false;
470483
return undefined;
471484
});
472485

@@ -475,8 +488,33 @@ describe('RewardsDashboard', () => {
475488
expect(getByTestId(REWARDS_VIEW_SELECTORS.VIP_BUTTON)).toBeOnTheScreen();
476489
});
477490

478-
it('navigates to VIP view when the VIP button is pressed', () => {
491+
it('navigates to VIP splash when the invite has not been accepted', () => {
492+
mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue(true);
493+
mockUseSelector.mockImplementation((selector) => {
494+
if (selector === selectActiveTab)
495+
return defaultSelectorValues.activeTab;
496+
if (selector === selectRewardsSubscriptionId)
497+
return defaultSelectorValues.subscriptionId;
498+
if (selector === selectIsCurrentSubscriptionVipEnabled) return true;
499+
if (selector === selectHideUnlinkedAccountsBanner)
500+
return defaultSelectorValues.hideUnlinkedAccountsBanner;
501+
if (selector === selectHideCurrentAccountNotOptedInBannerArray)
502+
return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray;
503+
if (selector === selectSelectedAccountGroup)
504+
return defaultSelectorValues.selectedAccountGroup;
505+
if (selector === mockHasAcceptedVipInviteSelector) return false;
506+
return undefined;
507+
});
508+
509+
const { getByTestId } = render(<RewardsDashboard />);
510+
fireEvent.press(getByTestId(REWARDS_VIEW_SELECTORS.VIP_BUTTON));
511+
512+
expect(mockNavigate).toHaveBeenCalledWith(Routes.REWARDS_VIP_SPLASH_VIEW);
513+
});
514+
515+
it('navigates to VIP view without splash when the invite was accepted', () => {
479516
mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue(true);
517+
mockHasAcceptedVipInviteSelector.mockReturnValue(true);
480518
mockUseSelector.mockImplementation((selector) => {
481519
if (selector === selectActiveTab)
482520
return defaultSelectorValues.activeTab;
@@ -489,6 +527,7 @@ describe('RewardsDashboard', () => {
489527
return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray;
490528
if (selector === selectSelectedAccountGroup)
491529
return defaultSelectorValues.selectedAccountGroup;
530+
if (selector === mockHasAcceptedVipInviteSelector) return true;
492531
return undefined;
493532
});
494533

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants';
1818
import Routes from '../../../../constants/navigation/Routes';
1919
import {
2020
selectActiveTab,
21+
selectHasAcceptedVipInvite,
2122
selectHideUnlinkedAccountsBanner,
2223
selectHideCurrentAccountNotOptedInBannerArray,
2324
} from '../../../../reducers/rewards/selectors';
@@ -52,6 +53,9 @@ const RewardsDashboard: React.FC = () => {
5253
const navigation = useNavigation();
5354
const subscriptionId = useSelector(selectRewardsSubscriptionId);
5455
const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled);
56+
const hasAcceptedVipInvite = useSelector(
57+
selectHasAcceptedVipInvite(subscriptionId),
58+
);
5559
const activeTab = useSelector(selectActiveTab);
5660
const { trackEvent, createEventBuilder } = useAnalytics();
5761
const hasTrackedDashboardViewed = useRef(false);
@@ -256,6 +260,14 @@ const RewardsDashboard: React.FC = () => {
256260
})();
257261
}, [isVipEnabled, subscriptionId]);
258262

263+
const handleVipPress = useCallback(() => {
264+
navigation.navigate(
265+
hasAcceptedVipInvite
266+
? Routes.REWARDS_VIP_VIEW
267+
: Routes.REWARDS_VIP_SPLASH_VIEW,
268+
);
269+
}, [hasAcceptedVipInvite, navigation]);
270+
259271
useEffect(() => {
260272
trackEvent(
261273
createEventBuilder(MetaMetricsEvents.REWARDS_DASHBOARD_TAB_VIEWED)
@@ -277,7 +289,7 @@ const RewardsDashboard: React.FC = () => {
277289
{isVipEnabled && (
278290
<Pressable
279291
accessibilityRole="button"
280-
onPress={() => navigation.navigate(Routes.REWARDS_VIP_VIEW)}
292+
onPress={handleVipPress}
281293
style={tw.style('h-8 w-8 items-center justify-center')}
282294
testID={REWARDS_VIEW_SELECTORS.VIP_BUTTON}
283295
>

0 commit comments

Comments
 (0)