diff --git a/app/actions/rewards/index.ts b/app/actions/rewards/index.ts
index b7080eef8ca..f4ec56c9679 100644
--- a/app/actions/rewards/index.ts
+++ b/app/actions/rewards/index.ts
@@ -12,6 +12,7 @@ export {
setGeoRewardsMetadataLoading,
setActiveBoosts,
setActiveBoostsLoading,
+ acceptVipInvite,
} from '../../reducers/rewards';
export type { RewardsState } from '../../reducers/rewards';
diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx
index 93b0821800f..ae4fbcffbf2 100644
--- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx
+++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx
@@ -2185,6 +2185,33 @@ describe('PredictBuyPreview', () => {
).toBeOnTheScreen();
});
+ it('tracks initiated trade transaction with explore entry point', () => {
+ mockUseRoute.mockReturnValue({
+ ...mockRoute,
+ params: {
+ ...mockRoute.params,
+ entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
+ },
+ });
+ mockBalance = 1000;
+ mockBalanceLoading = false;
+
+ renderWithProvider(, { state: initialState });
+
+ const trackPredictOrderEvent =
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ require('../../../../../core/Engine').context.PredictController
+ .trackPredictOrderEvent;
+
+ expect(trackPredictOrderEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ analyticsProperties: expect.objectContaining({
+ entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
+ }),
+ }),
+ );
+ });
+
it('handles undefined entryPoint with fallback', () => {
const routeWithoutEntryPoint = {
...mockRoute,
diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx
index bcee89b6368..d715e3eda3e 100644
--- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx
+++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx
@@ -132,7 +132,7 @@ jest.mock('../../components/PredictMarket', () => {
const { View, Text } = jest.requireActual('react-native');
return {
__esModule: true,
- default: jest.fn(({ testID }) => (
+ default: jest.fn(({ testID, entryPoint }) => (
Market Card
@@ -140,6 +140,14 @@ jest.mock('../../components/PredictMarket', () => {
};
});
+import PredictMarket from '../../components/PredictMarket';
+import { PredictEventValues } from '../../constants/eventNames';
+
+const mockPredictMarket = PredictMarket as jest.Mock;
+
+const getPredictMarketEntryPoints = () =>
+ mockPredictMarket.mock.calls.map(([props]) => props.entryPoint);
+
jest.mock('../../components/PredictMarketSkeleton', () => {
const { View } = jest.requireActual('react-native');
return {
@@ -795,7 +803,7 @@ describe('PredictFeed', () => {
);
});
- it('starts session with undefined entry point when not provided', () => {
+ it('preserves an unattributed session when entry point is not provided', () => {
mockUseRoute.mockReturnValue({
params: {},
});
@@ -807,6 +815,72 @@ describe('PredictFeed', () => {
'trending',
);
});
+
+ it('defaults market list items to predict_feed when entry point is not provided', () => {
+ mockUseRoute.mockReturnValue({
+ params: {},
+ });
+
+ render();
+
+ const entryPoints = getPredictMarketEntryPoints();
+ expect(entryPoints.length).toBeGreaterThan(0);
+ expect(
+ entryPoints.every(
+ (entryPoint) =>
+ entryPoint === PredictEventValues.ENTRY_POINT.PREDICT_FEED,
+ ),
+ ).toBe(true);
+ });
+
+ it('passes explore entryPoint to market list items from route params', () => {
+ mockUseRoute.mockReturnValue({
+ params: {
+ entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
+ },
+ });
+
+ render();
+
+ expect(mockSessionManager.startSession).toHaveBeenCalledWith(
+ PredictEventValues.ENTRY_POINT.EXPLORE,
+ 'trending',
+ );
+ const entryPoints = getPredictMarketEntryPoints();
+ expect(entryPoints.length).toBeGreaterThan(0);
+ expect(
+ entryPoints.every(
+ (entryPoint) => entryPoint === PredictEventValues.ENTRY_POINT.EXPLORE,
+ ),
+ ).toBe(true);
+ });
+
+ it('uses prop entryPoint for embedded feed list items and session attribution', () => {
+ mockUseRoute.mockReturnValue({
+ params: {
+ entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
+ },
+ });
+
+ render(
+ ,
+ );
+
+ expect(mockSessionManager.startSession).toHaveBeenCalledWith(
+ PredictEventValues.ENTRY_POINT.HOME_SECTION,
+ 'trending',
+ );
+ const entryPoints = getPredictMarketEntryPoints();
+ expect(entryPoints.length).toBeGreaterThan(0);
+ expect(
+ entryPoints.every(
+ (entryPoint) =>
+ entryPoint === PredictEventValues.ENTRY_POINT.HOME_SECTION,
+ ),
+ ).toBe(true);
+ });
});
describe('search debounce behavior', () => {
diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx
index 300e11bfee1..3005b985d81 100644
--- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx
+++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx
@@ -252,6 +252,7 @@ const PredictMarketListItem: React.FC = ({
interface PredictTabContentProps {
category: PredictCategory;
isActive: boolean;
+ listEntryPoint: PredictEntryPoint;
scrollHandler: ReturnType;
headerHeight: number;
tabBarHeight: number;
@@ -263,6 +264,7 @@ interface PredictTabContentProps {
const PredictTabContent: React.FC = ({
category,
isActive,
+ listEntryPoint,
scrollHandler,
headerHeight,
tabBarHeight,
@@ -317,7 +319,7 @@ const PredictTabContent: React.FC = ({
(info: { item: PredictMarketType; index: number }) => (
= ({
transactionActiveAbTests={transactionActiveAbTests}
/>
),
- [category, transactionActiveAbTests],
+ [category, listEntryPoint, transactionActiveAbTests],
);
const keyExtractor = useCallback((item: PredictMarketType) => item.id, []);
@@ -448,6 +450,7 @@ interface PredictFeedTabsProps {
tabs: FeedTab[];
activeIndex: number;
onPageChange: (index: number) => void;
+ listEntryPoint: PredictEntryPoint;
scrollHandler: ReturnType;
headerHeight: number;
tabBarHeight: number;
@@ -460,6 +463,7 @@ const PredictFeedTabs: React.FC = ({
tabs,
activeIndex,
onPageChange,
+ listEntryPoint,
scrollHandler,
headerHeight,
tabBarHeight,
@@ -499,6 +503,7 @@ const PredictFeedTabs: React.FC = ({
= ({
interface PredictFeedProps {
hideHeader?: boolean;
+ entryPoint?: PredictEntryPoint;
onHeaderHiddenChange?: (hidden: boolean) => void;
walletHeaderTranslateY?: SharedValue;
walletHeaderHeight?: number;
@@ -651,6 +657,7 @@ interface PredictFeedProps {
const PredictFeed: React.FC = ({
hideHeader = false,
+ entryPoint: propEntryPoint,
onHeaderHiddenChange,
walletHeaderTranslateY,
walletHeaderHeight,
@@ -663,6 +670,9 @@ const PredictFeed: React.FC = ({
const route =
useRoute>();
const transactionActiveAbTests = route.params?.transactionActiveAbTests;
+ const feedEntryPoint = propEntryPoint ?? route.params?.entryPoint;
+ const listEntryPoint =
+ feedEntryPoint ?? PredictEventValues.ENTRY_POINT.PREDICT_FEED;
const headerRef = useRef(null);
const tabBarRef = useRef(null);
@@ -691,20 +701,20 @@ const PredictFeed: React.FC = ({
traceName: TraceName.PredictFeedView,
conditions: [!isSearchVisible],
debugContext: {
- entryPoint: route.params?.entryPoint,
+ entryPoint: feedEntryPoint,
isSearchVisible,
},
});
useEffect(() => {
sessionManager.enableAppStateListener();
- sessionManager.startSession(route.params?.entryPoint, initialTabKey);
+ sessionManager.startSession(feedEntryPoint, initialTabKey);
return () => {
sessionManager.endSession();
sessionManager.disableAppStateListener();
};
- }, [route.params?.entryPoint, sessionManager, initialTabKey]);
+ }, [feedEntryPoint, sessionManager, initialTabKey]);
useFocusEffect(
useCallback(() => {
@@ -808,6 +818,7 @@ const PredictFeed: React.FC = ({
tabs={tabs}
activeIndex={activeIndex}
onPageChange={handlePageChange}
+ listEntryPoint={listEntryPoint}
scrollHandler={scrollHandler}
headerHeight={headerHeight}
tabBarHeight={tabBarHeight + 6}
diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
index a046f210f1d..618cd41fbcb 100644
--- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
+++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx
@@ -798,6 +798,31 @@ describe('PredictMarketDetails', () => {
expect(screen.getByText(mockMarket.title)).toBeOnTheScreen();
});
+ it('tracks market details opened with explore entry point from route params', async () => {
+ setupPredictMarketDetailsTest(
+ {},
+ {
+ params: {
+ marketId: 'market-1',
+ entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
+ },
+ },
+ );
+
+ const trackMarketDetailsOpened =
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ require('../../../../../core/Engine').context.PredictController
+ .trackMarketDetailsOpened;
+
+ await waitFor(() => {
+ expect(trackMarketDetailsOpened).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
+ }),
+ );
+ });
+ });
+
it('displays loading state when market is fetching', () => {
setupPredictMarketDetailsTest(
{},
diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx
index 720413085ac..921c1fa063d 100644
--- a/app/components/UI/Rewards/RewardsNavigator.test.tsx
+++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx
@@ -78,6 +78,18 @@ jest.mock('./Views/RewardsVipView', () => {
};
});
+jest.mock('./Views/RewardsVipSplashView', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View, Text } = jest.requireActual('react-native');
+ return function MockRewardsVipSplashView() {
+ return ReactActual.createElement(
+ View,
+ { testID: 'rewards-vip-splash-view' },
+ ReactActual.createElement(Text, null, 'Rewards VIP Splash View'),
+ );
+ };
+});
+
jest.mock('./Views/CampaignTourStepView', () => {
const ReactActual = jest.requireActual('react');
const { View, Text } = jest.requireActual('react-native');
diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx
index 02b7020a655..c42ef4620f4 100644
--- a/app/components/UI/Rewards/RewardsNavigator.tsx
+++ b/app/components/UI/Rewards/RewardsNavigator.tsx
@@ -9,6 +9,7 @@ import OnboardingNavigator from './OnboardingNavigator';
import RewardsDashboard from './Views/RewardsDashboard';
import ReferralRewardsView from './Views/RewardsReferralView';
import RewardsSettingsView from './Views/RewardsSettingsView';
+import RewardsVipSplashView from './Views/RewardsVipSplashView';
import RewardsVipView from './Views/RewardsVipView';
import RewardsVipTiersView from './Views/RewardsVipTiersView';
import CampaignsView from './Views/CampaignsView';
@@ -225,6 +226,17 @@ const RewardsNavigator: React.FC = () => {
component={RewardsSettingsView}
options={{ headerShown: false }}
/>
+
{
// Mock selectors
jest.mock('../../../../reducers/rewards/selectors', () => ({
selectActiveTab: jest.fn(),
+ selectHasAcceptedVipInvite: jest.fn(),
selectHideCurrentAccountNotOptedInBannerArray: jest.fn(),
selectHideUnlinkedAccountsBanner: jest.fn(),
}));
@@ -55,6 +56,7 @@ jest.mock(
import {
selectActiveTab,
+ selectHasAcceptedVipInvite,
selectHideUnlinkedAccountsBanner,
selectHideCurrentAccountNotOptedInBannerArray,
} from '../../../../reducers/rewards/selectors';
@@ -67,6 +69,11 @@ import { selectSelectedAccountGroup } from '../../../../selectors/multichainAcco
const mockSelectActiveTab = selectActiveTab as jest.MockedFunction<
typeof selectActiveTab
>;
+const mockSelectHasAcceptedVipInvite =
+ selectHasAcceptedVipInvite as jest.MockedFunction<
+ typeof selectHasAcceptedVipInvite
+ >;
+const mockHasAcceptedVipInviteSelector = jest.fn();
const mockSelectRewardsSubscriptionId =
selectRewardsSubscriptionId as jest.MockedFunction<
typeof selectRewardsSubscriptionId
@@ -327,6 +334,10 @@ describe('RewardsDashboard', () => {
mockSelectSelectedAccountGroup.mockReturnValue(
defaultSelectorValues.selectedAccountGroup,
);
+ mockSelectHasAcceptedVipInvite.mockReturnValue(
+ mockHasAcceptedVipInviteSelector,
+ );
+ mockHasAcceptedVipInviteSelector.mockReturnValue(false);
// Setup hook mocks
mockUseRewardOptinSummary.mockReturnValue(
@@ -352,6 +363,7 @@ describe('RewardsDashboard', () => {
return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray;
if (selector === selectSelectedAccountGroup)
return defaultSelectorValues.selectedAccountGroup;
+ if (selector === mockHasAcceptedVipInviteSelector) return false;
return undefined;
});
});
@@ -467,6 +479,7 @@ describe('RewardsDashboard', () => {
return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray;
if (selector === selectSelectedAccountGroup)
return defaultSelectorValues.selectedAccountGroup;
+ if (selector === mockHasAcceptedVipInviteSelector) return false;
return undefined;
});
@@ -475,8 +488,33 @@ describe('RewardsDashboard', () => {
expect(getByTestId(REWARDS_VIEW_SELECTORS.VIP_BUTTON)).toBeOnTheScreen();
});
- it('navigates to VIP view when the VIP button is pressed', () => {
+ it('navigates to VIP splash when the invite has not been accepted', () => {
+ mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue(true);
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectActiveTab)
+ return defaultSelectorValues.activeTab;
+ if (selector === selectRewardsSubscriptionId)
+ return defaultSelectorValues.subscriptionId;
+ if (selector === selectIsCurrentSubscriptionVipEnabled) return true;
+ if (selector === selectHideUnlinkedAccountsBanner)
+ return defaultSelectorValues.hideUnlinkedAccountsBanner;
+ if (selector === selectHideCurrentAccountNotOptedInBannerArray)
+ return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray;
+ if (selector === selectSelectedAccountGroup)
+ return defaultSelectorValues.selectedAccountGroup;
+ if (selector === mockHasAcceptedVipInviteSelector) return false;
+ return undefined;
+ });
+
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId(REWARDS_VIEW_SELECTORS.VIP_BUTTON));
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.REWARDS_VIP_SPLASH_VIEW);
+ });
+
+ it('navigates to VIP view without splash when the invite was accepted', () => {
mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue(true);
+ mockHasAcceptedVipInviteSelector.mockReturnValue(true);
mockUseSelector.mockImplementation((selector) => {
if (selector === selectActiveTab)
return defaultSelectorValues.activeTab;
@@ -489,6 +527,7 @@ describe('RewardsDashboard', () => {
return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray;
if (selector === selectSelectedAccountGroup)
return defaultSelectorValues.selectedAccountGroup;
+ if (selector === mockHasAcceptedVipInviteSelector) return true;
return undefined;
});
diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx
index b4c49766ae8..feb0d87fa88 100644
--- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx
+++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx
@@ -18,6 +18,7 @@ import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants';
import Routes from '../../../../constants/navigation/Routes';
import {
selectActiveTab,
+ selectHasAcceptedVipInvite,
selectHideUnlinkedAccountsBanner,
selectHideCurrentAccountNotOptedInBannerArray,
} from '../../../../reducers/rewards/selectors';
@@ -52,6 +53,9 @@ const RewardsDashboard: React.FC = () => {
const navigation = useNavigation();
const subscriptionId = useSelector(selectRewardsSubscriptionId);
const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled);
+ const hasAcceptedVipInvite = useSelector(
+ selectHasAcceptedVipInvite(subscriptionId),
+ );
const activeTab = useSelector(selectActiveTab);
const { trackEvent, createEventBuilder } = useAnalytics();
const hasTrackedDashboardViewed = useRef(false);
@@ -256,6 +260,14 @@ const RewardsDashboard: React.FC = () => {
})();
}, [isVipEnabled, subscriptionId]);
+ const handleVipPress = useCallback(() => {
+ navigation.navigate(
+ hasAcceptedVipInvite
+ ? Routes.REWARDS_VIP_VIEW
+ : Routes.REWARDS_VIP_SPLASH_VIEW,
+ );
+ }, [hasAcceptedVipInvite, navigation]);
+
useEffect(() => {
trackEvent(
createEventBuilder(MetaMetricsEvents.REWARDS_DASHBOARD_TAB_VIEWED)
@@ -277,7 +289,7 @@ const RewardsDashboard: React.FC = () => {
{isVipEnabled && (
navigation.navigate(Routes.REWARDS_VIP_VIEW)}
+ onPress={handleVipPress}
style={tw.style('h-8 w-8 items-center justify-center')}
testID={REWARDS_VIEW_SELECTORS.VIP_BUTTON}
>
diff --git a/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx
new file mode 100644
index 00000000000..6b89b0a85b8
--- /dev/null
+++ b/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx
@@ -0,0 +1,237 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import { StackActions } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
+import Routes from '../../../../constants/navigation/Routes';
+import {
+ selectIsCurrentSubscriptionVipEnabled,
+ selectRewardsSubscriptionId,
+} from '../../../../selectors/rewards';
+import { VIP_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipSplashScreen';
+import { useVipDashboard } from '../hooks/useVipDashboard';
+import RewardsVipSplashView from './RewardsVipSplashView';
+
+const mockNavigateDispatch = jest.fn();
+const mockGoBack = jest.fn();
+const mockNavigate = jest.fn();
+const mockSubscriptionId = 'test-subscription-id';
+let mockIsVipEnabled = true;
+let mockCanGoBack = true;
+let mockVipSplashAccepted: Record = {};
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ useNavigation: () => ({
+ canGoBack: () => mockCanGoBack,
+ dispatch: mockNavigateDispatch,
+ goBack: mockGoBack,
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+jest.mock('@metamask/design-system-react-native', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View, Text } = jest.requireActual('react-native');
+
+ return {
+ Box: ({
+ children,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ }) => ReactActual.createElement(View, { testID }, children),
+ FontWeight: { Bold: 'bold', Medium: 'medium' },
+ Text: ({ children, ...props }: { children?: React.ReactNode }) =>
+ ReactActual.createElement(Text, props, children),
+ TextVariant: { BodyMd: 'bodyMd' },
+ };
+});
+
+jest.mock('react-native-linear-gradient', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockLinearGradient({
+ children,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ }) {
+ return ReactActual.createElement(View, { testID }, children);
+ };
+});
+
+jest.mock('@react-native-masked-view/masked-view', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockMaskedView({
+ children,
+ maskElement,
+ }: {
+ children?: React.ReactNode;
+ maskElement?: React.ReactNode;
+ }) {
+ return ReactActual.createElement(View, null, maskElement, children);
+ };
+});
+
+jest.mock('react-native-safe-area-context', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ SafeAreaView: ({
+ children,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ }) => ReactActual.createElement(View, { testID }, children),
+ };
+});
+
+jest.mock('../../../../images/rewards/vip_splash.png', () => 1);
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string) => {
+ const translations: Record = {
+ 'rewards.vip.splash_title': 'WELCOME\nTO GOLD\nFOX VIP',
+ 'rewards.vip.splash_description':
+ 'Unlock exclusive perks, early features, and curated rewards. By invitation only.',
+ 'rewards.vip.splash_accept_invite': 'Accept invite',
+ 'rewards.vip.splash_not_now': 'Not now',
+ };
+ return translations[key] ?? key;
+ }),
+}));
+
+jest.mock('../../../../selectors/rewards', () => ({
+ selectIsCurrentSubscriptionVipEnabled: jest.fn(),
+ selectRewardsSubscriptionId: jest.fn(),
+}));
+
+jest.mock('../../../Views/ErrorBoundary', () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+jest.mock('../hooks/useVipDashboard', () => ({
+ useVipDashboard: jest.fn(),
+}));
+
+const mockUseSelector = useSelector as jest.MockedFunction;
+const mockUseVipDashboard = useVipDashboard as jest.MockedFunction<
+ typeof useVipDashboard
+>;
+
+const getRewardsSelectorState = () => ({
+ rewards: {
+ vipSplashAccepted: mockVipSplashAccepted,
+ },
+});
+
+describe('RewardsVipSplashView', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsVipEnabled = true;
+ mockCanGoBack = true;
+ mockVipSplashAccepted = {};
+ mockUseVipDashboard.mockReturnValue({
+ dashboard: null,
+ isLoading: false,
+ hasError: false,
+ hasAttemptedFetch: true,
+ fetchVipDashboard: jest.fn(),
+ });
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectRewardsSubscriptionId) return mockSubscriptionId;
+ if (selector === selectIsCurrentSubscriptionVipEnabled) {
+ return mockIsVipEnabled;
+ }
+
+ return (
+ selector as (
+ state: ReturnType,
+ ) => unknown
+ )(getRewardsSelectorState());
+ });
+ });
+
+ it('renders the VIP splash when the invite has not been accepted', () => {
+ const { getAllByText, getByTestId, getByText } = render(
+ ,
+ );
+
+ expect(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER)).toBeOnTheScreen();
+ expect(getAllByText('WELCOME\nTO GOLD\nFOX VIP')[0]).toBeOnTheScreen();
+ expect(
+ getByText(
+ 'Unlock exclusive perks, early features, and curated rewards. By invitation only.',
+ ),
+ ).toBeOnTheScreen();
+ expect(mockUseVipDashboard).toHaveBeenCalled();
+ });
+
+ it('replaces with VIP view and lets VIP view accept the invite', () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.ACCEPT_BUTTON));
+
+ expect(mockNavigateDispatch).toHaveBeenCalledWith(
+ StackActions.replace(Routes.REWARDS_VIP_VIEW),
+ );
+ });
+
+ it('goes back when not now is pressed without accepting', () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.NOT_NOW_BUTTON));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ expect(mockNavigateDispatch).not.toHaveBeenCalledWith(
+ StackActions.replace(Routes.REWARDS_DASHBOARD),
+ );
+ });
+
+ it('replaces with dashboard on not now when there is no back route', () => {
+ mockCanGoBack = false;
+
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.NOT_NOW_BUTTON));
+
+ expect(mockGoBack).not.toHaveBeenCalled();
+ expect(mockNavigateDispatch).toHaveBeenCalledWith(
+ StackActions.replace(Routes.REWARDS_DASHBOARD),
+ );
+ });
+
+ it('replaces with VIP view when the invite is already accepted', () => {
+ mockVipSplashAccepted = { [mockSubscriptionId]: true };
+
+ const { queryByTestId } = render();
+
+ expect(queryByTestId(VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER)).toBeNull();
+ expect(mockNavigateDispatch).toHaveBeenCalledWith(
+ StackActions.replace(Routes.REWARDS_VIP_VIEW),
+ );
+ });
+
+ it('replaces with dashboard when the user cannot view VIP', () => {
+ mockIsVipEnabled = false;
+
+ const { queryByTestId } = render();
+
+ expect(queryByTestId(VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER)).toBeNull();
+ expect(mockNavigateDispatch).toHaveBeenCalledWith(
+ StackActions.replace(Routes.REWARDS_DASHBOARD),
+ );
+ });
+});
diff --git a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx
new file mode 100644
index 00000000000..6e7896ecdae
--- /dev/null
+++ b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx
@@ -0,0 +1,75 @@
+import React, { useCallback, useEffect } from 'react';
+import { StackActions, useNavigation } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
+import Routes from '../../../../constants/navigation/Routes';
+import { selectHasAcceptedVipInvite } from '../../../../reducers/rewards/selectors';
+import {
+ selectIsCurrentSubscriptionVipEnabled,
+ selectRewardsSubscriptionId,
+} from '../../../../selectors/rewards';
+import ErrorBoundary from '../../../Views/ErrorBoundary';
+import VipSplashScreen from '../components/Vip/VipSplashScreen';
+import { useVipDashboard } from '../hooks/useVipDashboard';
+
+const RewardsVipSplashViewContent: React.FC = () => {
+ const navigation = useNavigation();
+ const subscriptionId = useSelector(selectRewardsSubscriptionId);
+ const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled);
+ const hasAcceptedVipInvite = useSelector(
+ selectHasAcceptedVipInvite(subscriptionId),
+ );
+ const canViewVip = Boolean(subscriptionId && isVipEnabled);
+
+ useVipDashboard();
+
+ useEffect(() => {
+ if (!canViewVip) {
+ navigation.dispatch(StackActions.replace(Routes.REWARDS_DASHBOARD));
+ return;
+ }
+
+ if (hasAcceptedVipInvite) {
+ navigation.dispatch(StackActions.replace(Routes.REWARDS_VIP_VIEW));
+ }
+ }, [canViewVip, hasAcceptedVipInvite, navigation]);
+
+ const handleAcceptInvite = useCallback(() => {
+ if (!subscriptionId) {
+ return;
+ }
+
+ navigation.dispatch(StackActions.replace(Routes.REWARDS_VIP_VIEW));
+ }, [navigation, subscriptionId]);
+
+ const handleNotNow = useCallback(() => {
+ if (navigation.canGoBack()) {
+ navigation.goBack();
+ return;
+ }
+
+ navigation.dispatch(StackActions.replace(Routes.REWARDS_DASHBOARD));
+ }, [navigation]);
+
+ if (!canViewVip || hasAcceptedVipInvite) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+const RewardsVipSplashView: React.FC = () => {
+ const navigation = useNavigation();
+
+ return (
+
+
+
+ );
+};
+
+export default RewardsVipSplashView;
diff --git a/app/components/UI/Rewards/Views/RewardsVipView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipView.test.tsx
index 897a07128f7..b515c72addd 100644
--- a/app/components/UI/Rewards/Views/RewardsVipView.test.tsx
+++ b/app/components/UI/Rewards/Views/RewardsVipView.test.tsx
@@ -1,8 +1,9 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import { StackActions } from '@react-navigation/native';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import Routes from '../../../../constants/navigation/Routes';
+import { acceptVipInvite } from '../../../../reducers/rewards';
import {
selectIsCurrentSubscriptionVipEnabled,
selectRewardsSubscriptionId,
@@ -18,10 +19,13 @@ import { VIP_POINTS_SECTION_TEST_IDS } from '../components/Vip/VipPointsSection'
import { VIP_FEE_TILE_TEST_IDS } from '../components/Vip/VipFeeTile';
const mockDispatch = jest.fn();
+const mockReduxDispatch = jest.fn();
const mockGoBack = jest.fn();
const mockNavigate = jest.fn();
+let mockVipSplashAccepted: Record = {};
jest.mock('react-redux', () => ({
+ useDispatch: jest.fn(() => mockReduxDispatch),
useSelector: jest.fn(),
}));
@@ -322,22 +326,37 @@ jest.mock('../hooks/useVipDashboard', () => ({
}));
const mockUseSelector = useSelector as jest.MockedFunction;
+const mockUseDispatch = useDispatch as jest.MockedFunction;
const mockUseTrackRewardsPageView =
useTrackRewardsPageView as jest.MockedFunction<
typeof useTrackRewardsPageView
>;
+const getRewardsSelectorState = () => ({
+ user: {
+ appTheme: 'dark',
+ },
+ rewards: {
+ referralCode: null,
+ vipSplashAccepted: mockVipSplashAccepted,
+ },
+});
+
const mockSubscribed = () => {
mockUseSelector.mockImplementation((selector) => {
if (selector === selectRewardsSubscriptionId) return 'test-subscription-id';
if (selector === selectIsCurrentSubscriptionVipEnabled) return true;
- return undefined;
+ return (
+ selector as (state: ReturnType) => unknown
+ )(getRewardsSelectorState());
});
};
describe('RewardsVipView', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockVipSplashAccepted = {};
+ mockUseDispatch.mockReturnValue(mockReduxDispatch);
mockFetch.mockReset();
mockSubscribed();
mockUseVipDashboard.mockReturnValue({
@@ -349,6 +368,24 @@ describe('RewardsVipView', () => {
});
});
+ it('accepts the VIP invite on mount when it has not been accepted', () => {
+ render();
+
+ expect(mockReduxDispatch).toHaveBeenCalledWith(
+ acceptVipInvite({ subscriptionId: 'test-subscription-id' }),
+ );
+ });
+
+ it('does not accept the VIP invite again when it was already accepted', () => {
+ mockVipSplashAccepted = { 'test-subscription-id': true };
+
+ render();
+
+ expect(mockReduxDispatch).not.toHaveBeenCalledWith(
+ acceptVipInvite({ subscriptionId: 'test-subscription-id' }),
+ );
+ });
+
it('renders the guarded VIP shell with the pilot title and invite button', () => {
mockUseVipDashboard.mockReturnValue({
dashboard: defaultDashboard,
diff --git a/app/components/UI/Rewards/Views/RewardsVipView.tsx b/app/components/UI/Rewards/Views/RewardsVipView.tsx
index 896c3898c47..cae0a6e57ad 100644
--- a/app/components/UI/Rewards/Views/RewardsVipView.tsx
+++ b/app/components/UI/Rewards/Views/RewardsVipView.tsx
@@ -16,9 +16,10 @@ import {
} from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { strings } from '../../../../../locales/i18n';
import Routes from '../../../../constants/navigation/Routes';
+import { acceptVipInvite } from '../../../../reducers/rewards';
import {
selectIsCurrentSubscriptionVipEnabled,
selectRewardsSubscriptionId,
@@ -36,7 +37,10 @@ import VipPointsSection from '../components/Vip/VipPointsSection';
import VipTierProgressCard from '../components/Vip/VipTierProgressCard';
import VipVolumeSection from '../components/Vip/VipVolumeSection';
import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants';
-import { selectReferralCode } from '../../../../reducers/rewards/selectors';
+import {
+ selectHasAcceptedVipInvite,
+ selectReferralCode,
+} from '../../../../reducers/rewards/selectors';
import { formatCompactValue } from '../utils/formatUtils';
export const REWARDS_VIP_VIEW_TEST_IDS = {
@@ -59,11 +63,15 @@ const BENEFIT_TILE_SNAP_INTERVAL = VIP_FEE_TILE_WIDTH + BENEFIT_TILE_GAP;
const RewardsVipViewContent: React.FC = () => {
const tw = useTailwind();
+ const dispatch = useDispatch();
const navigation = useNavigation();
const subscriptionId = useSelector(selectRewardsSubscriptionId);
const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled);
const canViewVip = Boolean(subscriptionId && isVipEnabled);
const referralCode = useSelector(selectReferralCode);
+ const hasAcceptedVipInvite = useSelector(
+ selectHasAcceptedVipInvite(subscriptionId),
+ );
const {
dashboard,
@@ -84,6 +92,14 @@ const RewardsVipViewContent: React.FC = () => {
}
}, [canViewVip, navigation]);
+ useEffect(() => {
+ if (!canViewVip || !subscriptionId || hasAcceptedVipInvite) {
+ return;
+ }
+
+ dispatch(acceptVipInvite({ subscriptionId }));
+ }, [canViewVip, dispatch, hasAcceptedVipInvite, subscriptionId]);
+
const handleTiersPress = useCallback(() => {
navigation.navigate(Routes.REWARDS_VIP_TIERS_VIEW as never);
}, [navigation]);
diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx
index eb890ff034c..8d334d3dd70 100644
--- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx
@@ -35,6 +35,10 @@ jest.mock('../RewardsErrorBanner', () => {
});
jest.mock('../../../../../images/rewards/crown.svg', () => 'CrownIcon');
+jest.mock(
+ '../../../../../images/rewards/hypertracker.svg',
+ () => 'HyperTrackerLogo',
+);
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
@@ -51,7 +55,6 @@ jest.mock('../../../../../constants/navigation/Routes', () => ({
}));
const TEST_IDS = PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS;
-const CrownIcon = 'CrownIcon' as unknown as React.ComponentType;
const createPerpsEntry = (
overrides: Partial = {},
@@ -88,14 +91,10 @@ describe('PerpsTradingCampaignLeaderboard', () => {
});
it('navigates to in-app browser with HyperTracker attribution URL when brand is pressed', () => {
- const { getByText } = render(
+ const { getByTestId } = render(
,
);
- fireEvent.press(
- getByText(
- 'rewards.perps_trading_campaign.leaderboard_hypertracker_brand',
- ),
- );
+ fireEvent.press(getByTestId(TEST_IDS.HYPERTRACKER_LOGO));
expect(mockNavigate).toHaveBeenCalledWith(
'BrowserHome',
@@ -120,11 +119,11 @@ describe('PerpsTradingCampaignLeaderboard', () => {
referralCode: 'NEXT',
}),
];
- const { UNSAFE_queryAllByType } = render(
+ const { UNSAFE_queryAllByProps } = render(
,
);
- expect(UNSAFE_queryAllByType(CrownIcon)).toHaveLength(1);
+ expect(UNSAFE_queryAllByProps({ name: 'crown' })).toHaveLength(1);
});
it('hides crown in preview mode for perps winner ranks', () => {
@@ -132,7 +131,7 @@ describe('PerpsTradingCampaignLeaderboard', () => {
createPerpsEntry({ rank: 1, referralCode: 'AAA111' }),
createPerpsEntry({ rank: 2, referralCode: 'BBB222' }),
];
- const { UNSAFE_queryAllByType } = render(
+ const { UNSAFE_queryAllByProps } = render(
{
/>,
);
- expect(UNSAFE_queryAllByType(CrownIcon)).toHaveLength(0);
+ expect(UNSAFE_queryAllByProps({ name: 'crown' })).toHaveLength(0);
});
describe('split view top count (preview vs full, ranks 21–22 vs other)', () => {
diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx
index e905790e6a1..fe9b7fcda74 100644
--- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx
@@ -1,4 +1,5 @@
import React, { useCallback, useMemo } from 'react';
+import { Pressable } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import {
Box,
@@ -21,6 +22,7 @@ import {
HYPERTRACKER_ATTRIBUTION_URL,
PERPS_TRADING_MAX_WINNERS,
} from '../../utils/perpsCampaignConstants';
+import HyperTrackerLogo from '../../../../../images/rewards/hypertracker.svg';
export const PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS = {
CONTAINER: 'perps-campaign-leaderboard-container',
@@ -34,6 +36,7 @@ export const PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS = {
NOT_YET_COMPUTED: 'perps-campaign-leaderboard-not-yet-computed',
TOTAL_PARTICIPANTS: 'perps-campaign-leaderboard-total-participants',
POWERED_BY: 'perps-campaign-leaderboard-powered-by',
+ HYPERTRACKER_LOGO: 'perps-campaign-leaderboard-hypertracker-logo',
} as const;
const MAX_ENTRIES_LIMIT = 20;
@@ -219,25 +222,26 @@ const PerpsTradingCampaignLeaderboard: React.FC<
>
)}
-
- {strings(
- 'rewards.perps_trading_campaign.leaderboard_powered_by_prefix',
- )}
-
+
{strings(
- 'rewards.perps_trading_campaign.leaderboard_hypertracker_brand',
+ 'rewards.perps_trading_campaign.leaderboard_powered_by_prefix',
)}
-
+
+
+
+
);
};
diff --git a/app/components/UI/Rewards/components/Vip/Vip.constants.ts b/app/components/UI/Rewards/components/Vip/Vip.constants.ts
index 7764dfe247b..4455500879b 100644
--- a/app/components/UI/Rewards/components/Vip/Vip.constants.ts
+++ b/app/components/UI/Rewards/components/Vip/Vip.constants.ts
@@ -3,6 +3,12 @@ export const VIP_GOLD_TEXT_DEFAULT = '#EAD797FF';
export const VIP_GOLD_TEXT_MUTED = '#F7F0D699';
export const VIP_GOLD_BORDER_DEFAULT = '#CBAE6933';
export const VIP_GOLD_BACKGROUND_MUTED = '#EAD79726';
+export const VIP_SPLASH_BACKGROUND_GRADIENT_COLORS = [
+ '#AA985F26',
+ 'transparent',
+];
+export const VIP_SPLASH_TITLE_GRADIENT_COLORS = ['#FFE181', '#F7F0D6'];
+export const VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND = '#1E1C17';
export const VIP_GOLD_BACKGROUND_GRADIENT_COLORS = ['transparent', '#CBAE6950'];
export const VIP_GOLD_TIER_GRADIENT_COLORS = [
'rgba(234, 215, 151, 0.12)',
diff --git a/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx b/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx
new file mode 100644
index 00000000000..53f93d85c1b
--- /dev/null
+++ b/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx
@@ -0,0 +1,168 @@
+import MaskedView from '@react-native-masked-view/masked-view';
+import React from 'react';
+import { Image, Pressable } from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import {
+ Box,
+ FontWeight,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { strings } from '../../../../../../locales/i18n';
+import VipSplashFox from '../../../../../images/rewards/vip_splash.png';
+import {
+ VIP_GOLD_BORDER_DEFAULT,
+ VIP_GOLD_TEXT_DEFAULT,
+ VIP_GOLD_TEXT_MUTED,
+ VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND,
+ VIP_SPLASH_BACKGROUND_GRADIENT_COLORS,
+ VIP_SPLASH_TITLE_GRADIENT_COLORS,
+} from './Vip.constants';
+
+export const VIP_SPLASH_SCREEN_TEST_IDS = {
+ CONTAINER: 'rewards-vip-splash-screen',
+ TITLE: 'rewards-vip-splash-title',
+ DESCRIPTION: 'rewards-vip-splash-description',
+ FOX: 'rewards-vip-splash-fox',
+ ACCEPT_BUTTON: 'rewards-vip-splash-accept-button',
+ NOT_NOW_BUTTON: 'rewards-vip-splash-not-now-button',
+} as const;
+
+interface VipSplashScreenProps {
+ onAcceptInvite: () => void;
+ onNotNow: () => void;
+}
+
+const titleColorStyle = { color: VIP_SPLASH_TITLE_GRADIENT_COLORS[0] };
+const titleFontStyle = {
+ fontFamily: 'MMPoly-Regular',
+ fontWeight: '400' as const,
+ includeFontPadding: false,
+ letterSpacing: 0,
+};
+const descriptionColorStyle = { color: VIP_GOLD_TEXT_MUTED };
+const acceptButtonBorderStyle = { borderColor: VIP_GOLD_BORDER_DEFAULT };
+const acceptButtonBackgroundStyle = {
+ backgroundColor: VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND,
+};
+const acceptButtonTextStyle = { color: VIP_GOLD_TEXT_DEFAULT };
+const notNowButtonTextStyle = { color: VIP_GOLD_TEXT_MUTED };
+
+const VipGradientTitle = () => {
+ const tw = useTailwind();
+ const title = strings('rewards.vip.splash_title');
+ const titleStyle = tw.style(
+ 'text-center text-[42px] leading-[42px]',
+ titleFontStyle,
+ titleColorStyle,
+ );
+
+ return (
+
+ {title}
+
+ }
+ style={tw.style('self-stretch')}
+ >
+
+
+ {title}
+
+
+
+ );
+};
+
+const VipSplashScreen: React.FC = ({
+ onAcceptInvite,
+ onNotNow,
+}) => {
+ const tw = useTailwind();
+
+ return (
+
+
+
+
+
+ {strings('rewards.vip.splash_description')}
+
+
+
+
+
+
+
+ {strings('rewards.vip.splash_accept_invite')}
+
+
+
+
+ {strings('rewards.vip.splash_not_now')}
+
+
+
+
+
+ );
+};
+
+export default VipSplashScreen;
diff --git a/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.test.tsx
index 3cbb049d236..c138470c66e 100644
--- a/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.test.tsx
+++ b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.test.tsx
@@ -11,6 +11,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider';
import TrendingTokensFullView, {
TrendingTokensData,
TrendingTokensDataProps,
+ TrendingTokensFullViewParams,
} from './TrendingTokensFullView';
import type { TrendingAsset } from '@metamask/assets-controllers';
import { useTrendingSearch } from '../../hooks/useTrendingSearch/useTrendingSearch';
@@ -40,12 +41,17 @@ const initialMetrics: Metrics = {
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
+const mockUseRoute = jest.fn<
+ { params: TrendingTokensFullViewParams | undefined },
+ []
+>(() => ({ params: undefined }));
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
navigate: mockNavigate,
goBack: mockGoBack,
}),
+ useRoute: () => mockUseRoute(),
createNavigatorFactory: () => ({}),
}));
@@ -244,6 +250,7 @@ describe('TrendingTokensFullView', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockUseRoute.mockReturnValue({ params: undefined });
const mocks = arrangeMocks();
mocks.setTrendingRequestMock({ results: [createMockToken()] });
mocks.setTrendingSearchMock({ data: [createMockToken()] });
@@ -333,6 +340,21 @@ describe('TrendingTokensFullView', () => {
});
});
+ it('applies initial time option from route params', () => {
+ mockUseRoute.mockReturnValue({
+ params: { initialTimeOption: TimeOption.OneHour },
+ });
+
+ const { getByTestId } = renderTrendingFullView();
+
+ expect(getByTestId('24h-button')).toHaveTextContent('1h');
+ expect(mockUseTrendingSearch).toHaveBeenCalledWith({
+ sortBy: 'h1_trending',
+ chainIds: null,
+ searchQuery: undefined,
+ });
+ });
+
it('calls refetch when pull-to-refresh is triggered', () => {
const mockTokens = [
createMockToken({ name: 'Token 1', assetId: 'eip155:1/erc20:0x123' }),
diff --git a/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx
index 893a3e11fd7..0650e9088c8 100644
--- a/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx
+++ b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import { View, TouchableOpacity, RefreshControl } from 'react-native';
+import { useRoute, type RouteProp } from '@react-navigation/native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { strings } from '../../../../../../locales/i18n';
import TrendingTokensList, {
@@ -21,6 +22,7 @@ import {
} from '@metamask/design-system-react-native';
import {
TrendingTokenTimeBottomSheet,
+ mapTimeOptionToSortBy,
PriceChangeOption,
TimeOption,
} from '../../components/TrendingTokensBottomSheet';
@@ -35,6 +37,10 @@ import TokenListPageLayout from '../../components/TokenListPageLayout/TokenListP
import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList';
import type { Theme } from '../../../../../util/theme/models';
+export interface TrendingTokensFullViewParams {
+ initialTimeOption?: TimeOption;
+}
+
export interface TrendingTokensDataProps {
isLoading: boolean;
refreshing: boolean;
@@ -113,9 +119,16 @@ export const TrendingTokensData = (props: TrendingTokensDataProps) => {
const TrendingTokensFullView = () => {
const tw = useTailwind();
const sessionManager = TrendingFeedSessionManager.getInstance();
- const filters = useTokenListFilters();
+ const { params } =
+ useRoute<
+ RouteProp<{ TrendingTokensFullView: TrendingTokensFullViewParams }>
+ >();
+ const initialTimeOption = params?.initialTimeOption;
+ const filters = useTokenListFilters({ timeOption: initialTimeOption });
- const [sortBy, setSortBy] = useState(undefined);
+ const [sortBy, setSortBy] = useState(
+ initialTimeOption ? mapTimeOptionToSortBy(initialTimeOption) : undefined,
+ );
const [showTimeBottomSheet, setShowTimeBottomSheet] = useState(false);
const {
diff --git a/app/components/UI/Trending/components/PillScrollList/PillScrollList.test.tsx b/app/components/UI/Trending/components/PillScrollList/PillScrollList.test.tsx
index de5e3be2302..a89844c5f40 100644
--- a/app/components/UI/Trending/components/PillScrollList/PillScrollList.test.tsx
+++ b/app/components/UI/Trending/components/PillScrollList/PillScrollList.test.tsx
@@ -60,6 +60,26 @@ describe('PillScrollList', () => {
expect(renderItem).toHaveBeenCalledTimes(3);
});
+ it('supports a custom row count', () => {
+ const { getByTestId } = render(
+ (
+ {String(index)}
+ )}
+ keyExtractor={(item: { id: string }) => item.id}
+ Skeleton={Skeleton}
+ listTestId="pills-list"
+ />,
+ );
+
+ expect(getByTestId('pills-list-row-0')).toBeTruthy();
+ expect(getByTestId('pills-list-row-1')).toBeTruthy();
+ expect(getByTestId('pills-list-row-2')).toBeTruthy();
+ });
+
it('respects maxPills when slicing data before splitting', () => {
const { getByTestId, queryByTestId } = render(
(items: T[]): [T[], T[]] {
- if (items.length === 0) return [[], []];
- const mid = Math.ceil(items.length / 2);
- return [items.slice(0, mid), items.slice(mid)];
+interface PillRow {
+ items: T[];
+ startIndex: number;
+}
+
+const normalizeRowCount = (rowCount: number) =>
+ Math.max(1, Math.floor(rowCount));
+
+function splitIntoRows(items: T[], rowCount: number): PillRow[] {
+ if (items.length === 0) return [];
+
+ const rows: PillRow[] = [];
+ let start = 0;
+
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ const remainingItems = items.length - start;
+ const remainingRows = rowCount - rowIndex;
+ const rowSize = Math.ceil(remainingItems / remainingRows);
+ const row = items.slice(start, start + rowSize);
+
+ if (row.length > 0) {
+ rows.push({ items: row, startIndex: start });
+ }
+
+ start += rowSize;
+ }
+
+ return rows;
}
export interface PillScrollListProps {
@@ -20,9 +45,11 @@ export interface PillScrollListProps {
isLoading: boolean;
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
- Skeleton: React.ComponentType;
+ Skeleton: React.ComponentType<{ rowCount?: number }>;
/** @default 12 */
maxPills?: number;
+ /** @default 2 */
+ rowCount?: number;
listTestId?: string;
/**
* Outer wrapper Tailwind classes. Defaults to Explore spacing (`mt-3 mb-9`).
@@ -33,8 +60,8 @@ export interface PillScrollListProps {
}
/**
- * Two-row horizontal scroll of pill-shaped items. Used for "crypto movers".
- * Splits incoming data evenly between the two rows.
+ * Multi-row horizontal scroll of pill-shaped items. Used for compact movers sections.
+ * Splits incoming data evenly between rows.
*/
const DEFAULT_WRAPPER_TW = '-mx-4 bg-transparent mt-3 mb-9' as const;
@@ -45,14 +72,16 @@ function PillScrollList({
keyExtractor,
Skeleton,
maxPills = DEFAULT_MAX_PILLS,
+ rowCount = DEFAULT_ROW_COUNT,
listTestId,
wrapperTwClassName = DEFAULT_WRAPPER_TW,
}: PillScrollListProps) {
const tw = useTailwind();
const displayData = useMemo(() => data.slice(0, maxPills), [data, maxPills]);
- const [row1, row2] = useMemo(
- () => splitIntoTwoRows(displayData),
- [displayData],
+ const normalizedRowCount = normalizeRowCount(rowCount);
+ const rows = useMemo(
+ () => splitIntoRows(displayData, normalizedRowCount),
+ [displayData, normalizedRowCount],
);
const renderRow = (items: T[], startIndex: number) =>
@@ -66,10 +95,10 @@ function PillScrollList({
{isLoading && (
-
+
)}
- {!isLoading && (row1.length > 0 || row2.length > 0) && (
+ {!isLoading && rows.length > 0 && (
({
contentContainerStyle={tw.style('flex-col px-4')}
>
- {row1.length > 0 ? (
-
- {renderRow(row1, 0)}
-
- ) : null}
- {row2.length > 0 ? (
+ {rows.map((row, rowIndex) => (
- {renderRow(row2, row1.length)}
+ {renderRow(row.items, row.startIndex)}
- ) : null}
+ ))}
)}
diff --git a/app/components/UI/Trending/components/SectionPillsSkeleton/SectionPillsSkeleton.tsx b/app/components/UI/Trending/components/SectionPillsSkeleton/SectionPillsSkeleton.tsx
index ff083270a5b..f4f968d76da 100644
--- a/app/components/UI/Trending/components/SectionPillsSkeleton/SectionPillsSkeleton.tsx
+++ b/app/components/UI/Trending/components/SectionPillsSkeleton/SectionPillsSkeleton.tsx
@@ -38,8 +38,15 @@ const SkeletonRow: React.FC<{ prefix: string }> = ({ prefix }) => (
);
-const SectionPillsSkeleton: React.FC = () => {
+interface SectionPillsSkeletonProps {
+ rowCount?: number;
+}
+
+const SectionPillsSkeleton: React.FC = ({
+ rowCount = 2,
+}) => {
const tw = useTailwind();
+ const normalizedRowCount = Math.max(1, Math.floor(rowCount));
return (
@@ -50,8 +57,9 @@ const SectionPillsSkeleton: React.FC = () => {
contentContainerStyle={tw.style('flex-col gap-2 pr-0')}
>
-
-
+ {Array.from({ length: normalizedRowCount }).map((_, rowIndex) => (
+
+ ))}
diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts
index 52dc086e260..0408b96ea9f 100644
--- a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts
+++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.test.ts
@@ -9,6 +9,7 @@ import { sortTrendingTokens } from '../../utils/sortTrendingTokens';
import {
PriceChangeOption,
SortDirection,
+ TimeOption,
} from '../../components/TrendingTokensBottomSheet';
// Mock dependencies
@@ -108,6 +109,7 @@ describe('useTrendingSearch', () => {
mockTrendingResults,
PriceChangeOption.PriceChange,
SortDirection.Descending,
+ undefined,
);
expect(result.current.isLoading).toBe(false);
});
@@ -133,6 +135,33 @@ describe('useTrendingSearch', () => {
mockTrendingResults,
PriceChangeOption.MarketCap,
SortDirection.Ascending,
+ undefined,
+ );
+ });
+
+ it('passes custom time option to sortTrendingTokens', async () => {
+ const sortedResults = [mockTrendingResults[1], mockTrendingResults[0]];
+ mockSortTrendingTokens.mockReturnValue(sortedResults);
+
+ const { result } = renderHookWithProvider(() =>
+ useTrendingSearch({
+ sortTrendingTokensOptions: {
+ option: PriceChangeOption.PriceChange,
+ direction: SortDirection.Descending,
+ timeOption: TimeOption.OneHour,
+ },
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.data).toEqual(sortedResults);
+ });
+
+ expect(mockSortTrendingTokens).toHaveBeenCalledWith(
+ mockTrendingResults,
+ PriceChangeOption.PriceChange,
+ SortDirection.Descending,
+ TimeOption.OneHour,
);
});
diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts
index 9821d5f7f05..3b6dda9c7e7 100644
--- a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts
+++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts
@@ -7,6 +7,7 @@ import { sortTrendingTokens } from '../../utils/sortTrendingTokens';
import {
PriceChangeOption,
SortDirection,
+ TimeOption,
} from '../../components/TrendingTokensBottomSheet';
import { isEqual } from 'lodash';
@@ -42,6 +43,7 @@ export const useTrendingSearch = (opts?: {
sortTrendingTokensOptions?: {
option: PriceChangeOption;
direction: SortDirection;
+ timeOption?: TimeOption;
};
}) => {
const {
@@ -99,6 +101,7 @@ export const useTrendingSearch = (opts?: {
trendingResults,
sortTrendingTokensOptions.option,
sortTrendingTokensOptions.direction,
+ sortTrendingTokensOptions.timeOption,
);
}
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx
index 8df0c54f38a..060a8cd93f7 100644
--- a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx
@@ -3,6 +3,7 @@ import { InteractionManager, Text } from 'react-native';
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import type { SectionRefreshHandle } from '../../types';
import HomepageDiscoveryTabs from './HomepageDiscoveryTabs';
+import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames';
import { HomeTabNames } from '../../hooks/useTabViewedEvent';
const mockTrackTabViewed = jest.fn();
@@ -213,6 +214,18 @@ describe('HomepageDiscoveryTabs', () => {
});
});
+ describe('Predictions tab', () => {
+ it('passes Predict feed entry point explicitly to embedded PredictFeed', async () => {
+ renderComponent();
+
+ await pressTab('Predictions');
+
+ expect(mockPredictFeedProps.current).toMatchObject({
+ entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
+ });
+ });
+ });
+
describe('walletHeaderOffset prop', () => {
it('renders without throwing when walletHeaderOffset is provided', () => {
expect(() => renderComponent({ walletHeaderOffset: 100 })).not.toThrow();
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx
index a21e3697aed..44d651f1e96 100644
--- a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx
@@ -25,6 +25,7 @@ import { TabsIconListRef } from '../../../../../component-library/components-tem
import Homepage from '../../Homepage';
import PerpsHomeView from '../../../../UI/Perps/Views/PerpsHomeView/PerpsHomeView';
import PredictFeed from '../../../../UI/Predict/views/PredictFeed';
+import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames';
import { PerpsConnectionProvider } from '../../../../UI/Perps/providers/PerpsConnectionProvider';
import {
PerpsStreamProvider,
@@ -388,6 +389,7 @@ const HomepageDiscoveryTabs = forwardRef<
{
- it('navigates without tab params for trending variant', () => {
+ it('navigates with an explicit entryPoint and no tab for trending variant', () => {
const navigate = jest.fn();
const navigation = { navigate } as unknown as AppNavigationProp;
- navigateToPredictionsList(navigation, 'trending');
+ navigateToPredictionsList(
+ navigation,
+ 'trending',
+ PredictEventValues.ENTRY_POINT.EXPLORE,
+ );
expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
screen: Routes.PREDICT.MARKET_LIST,
- params: undefined,
+ params: { entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE },
});
});
- it('includes tab param for sports variant', () => {
+ it('includes tab and explore entryPoint for sports variant', () => {
const navigate = jest.fn();
const navigation = { navigate } as unknown as AppNavigationProp;
- navigateToPredictionsList(navigation, 'sports');
+ navigateToPredictionsList(
+ navigation,
+ 'sports',
+ PredictEventValues.ENTRY_POINT.EXPLORE,
+ );
expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
screen: Routes.PREDICT.MARKET_LIST,
- params: { tab: 'sports' },
+ params: {
+ entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
+ tab: 'sports',
+ },
});
});
- it('includes tab param for crypto variant', () => {
+ it('includes tab and explore entryPoint for crypto variant', () => {
const navigate = jest.fn();
const navigation = { navigate } as unknown as AppNavigationProp;
- navigateToPredictionsList(navigation, 'crypto');
+ navigateToPredictionsList(
+ navigation,
+ 'crypto',
+ PredictEventValues.ENTRY_POINT.EXPLORE,
+ );
expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
screen: Routes.PREDICT.MARKET_LIST,
- params: { tab: 'crypto' },
+ params: {
+ entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
+ tab: 'crypto',
+ },
+ });
+ });
+
+ it('passes a custom entryPoint when provided', () => {
+ const navigate = jest.fn();
+ const navigation = { navigate } as unknown as AppNavigationProp;
+
+ navigateToPredictionsList(
+ navigation,
+ 'trending',
+ PredictEventValues.ENTRY_POINT.PREDICT_FEED,
+ );
+
+ expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ params: { entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED },
+ });
+ });
+
+ it('navigates from Explore with explore entryPoint', () => {
+ const navigate = jest.fn();
+ const navigation = { navigate } as unknown as AppNavigationProp;
+
+ navigateToExplorePredictionsList(navigation, 'trending');
+
+ expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ params: { entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE },
});
});
});
diff --git a/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts
index 897ee5a89ca..0d46f15377d 100644
--- a/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts
+++ b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts
@@ -1,5 +1,7 @@
import type { AppNavigationProp } from '../../../../../core/NavigationService/types';
import Routes from '../../../../../constants/navigation/Routes';
+import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames';
+import type { PredictEntryPoint } from '../../../../UI/Predict/types/navigation';
import type { PredictionsVariant } from './usePredictionsFeed';
const VARIANT_TO_TAB: Record = {
@@ -13,10 +15,26 @@ const VARIANT_TO_TAB: Record = {
export const navigateToPredictionsList = (
navigation: AppNavigationProp,
variant: PredictionsVariant,
+ entryPoint: PredictEntryPoint = PredictEventValues.ENTRY_POINT.EXPLORE,
): void => {
const tab = VARIANT_TO_TAB[variant];
navigation.navigate(Routes.PREDICT.ROOT, {
screen: Routes.PREDICT.MARKET_LIST,
- params: tab ? { tab } : undefined,
+ params: {
+ entryPoint,
+ ...(tab && { tab }),
+ },
});
};
+
+/** Navigate from Explore prediction sections to the Predict market list. */
+export const navigateToExplorePredictionsList = (
+ navigation: AppNavigationProp,
+ variant: PredictionsVariant,
+): void => {
+ navigateToPredictionsList(
+ navigation,
+ variant,
+ PredictEventValues.ENTRY_POINT.EXPLORE,
+ );
+};
diff --git a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.test.tsx b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.test.tsx
index 25931d393cf..e5598c16d32 100644
--- a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.test.tsx
+++ b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.test.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import type { TrendingAsset } from '@metamask/assets-controllers';
+import { TimeOption } from '../../../../UI/Trending/components/TrendingTokensBottomSheet';
import CryptoMoversPillItem from './CryptoMoversPillItem';
const mockOnPress = jest.fn();
@@ -32,7 +33,7 @@ describe('CryptoMoversPillItem', () => {
expect(mockOnPress).toHaveBeenCalledTimes(1);
});
- it('omits change label when 24h change is missing or not a number', () => {
+ it('omits change label when selected interval change is missing or not a number', () => {
const { queryByText, rerender } = render(
,
);
@@ -52,11 +53,12 @@ describe('CryptoMoversPillItem', () => {
expect(queryByText(/%/)).toBeNull();
});
- it('renders zero and signed positive and negative 24h changes', () => {
+ it('renders zero and signed positive and negative changes for the selected interval', () => {
const { getByText, rerender } = render(
,
);
expect(getByText('0.00%')).toBeTruthy();
@@ -64,9 +66,10 @@ describe('CryptoMoversPillItem', () => {
rerender(
,
);
expect(getByText('+2.51%')).toBeTruthy();
@@ -74,9 +77,10 @@ describe('CryptoMoversPillItem', () => {
rerender(
,
);
expect(getByText('-1.20%')).toBeTruthy();
diff --git a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx
index 0961ce642ed..35589946f54 100644
--- a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx
+++ b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx
@@ -26,6 +26,7 @@ const LOGO_SIZE = 24;
interface CryptoMoversPillItemProps {
token: TrendingAsset;
index: number;
+ timeOption?: TimeOption;
/** Called synchronously before the card's press handler fires. */
onCardPress?: () => void;
}
@@ -33,6 +34,7 @@ interface CryptoMoversPillItemProps {
const CryptoMoversPillItem: React.FC = ({
token,
index,
+ timeOption = TimeOption.TwentyFourHours,
onCardPress,
}) => {
const { onPress: defaultOnPress } = useTrendingTokenPress({
@@ -54,9 +56,9 @@ const CryptoMoversPillItem: React.FC = ({
}, [token.assetId]);
const { changeLabel, changeTextColor } = useMemo(() => {
- const key = getPriceChangeFieldKey(TimeOption.TwentyFourHours);
+ const key = getPriceChangeFieldKey(timeOption);
return formatPercentChange(token.priceChangePct?.[key]);
- }, [token.priceChangePct]);
+ }, [timeOption, token.priceChangePct]);
const leading = useMemo(
() => (
diff --git a/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.test.ts b/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.test.ts
index 52a959990e5..03cc1965af0 100644
--- a/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.test.ts
+++ b/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.test.ts
@@ -1,6 +1,7 @@
import { renderHook, waitFor } from '@testing-library/react-native';
import type { TrendingAsset } from '@metamask/assets-controllers';
import { useTrendingSearch } from '../../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch';
+import { TimeOption } from '../../../../UI/Trending/components/TrendingTokensBottomSheet';
import type { RefreshConfig } from '../../hooks/useExploreRefresh';
import { useTokensFeed } from './useTokensFeed';
@@ -100,6 +101,27 @@ describe('useTokensFeed', () => {
expect(mockUseTrendingSearch).toHaveBeenCalledWith({
searchQuery: 'sol',
enableDebounce: false,
+ sortBy: undefined,
+ sortTrendingTokensOptions: undefined,
+ });
+ });
+
+ it('passes timeOption into request and local price-change sorting options', () => {
+ renderHook(() =>
+ useTokensFeed({
+ timeOption: TimeOption.OneHour,
+ }),
+ );
+
+ expect(mockUseTrendingSearch).toHaveBeenCalledWith({
+ searchQuery: undefined,
+ enableDebounce: false,
+ sortBy: 'h1_trending',
+ sortTrendingTokensOptions: {
+ option: 'price_change',
+ direction: 'descending',
+ timeOption: TimeOption.OneHour,
+ },
});
});
diff --git a/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.ts b/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.ts
index f8cd82256af..b3406a4f6ef 100644
--- a/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.ts
+++ b/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.ts
@@ -4,6 +4,12 @@ import { useTrendingSearch } from '../../../../UI/Trending/hooks/useTrendingSear
import { useFeedRefresh } from '../../hooks/useFeedRefresh';
import type { RefreshConfig } from '../../hooks/useExploreRefresh';
import { fuseSearch, TOKEN_FUSE_OPTIONS } from '../search-utils';
+import {
+ mapTimeOptionToSortBy,
+ PriceChangeOption,
+ SortDirection,
+ TimeOption,
+} from '../../../../UI/Trending/components/TrendingTokensBottomSheet';
interface UseTokensFeedOptions {
/** Search query; when present, results are sorted by market cap descending. */
@@ -14,6 +20,8 @@ interface UseTokensFeedOptions {
* Use for surfaces that don't display a security badge.
*/
hideRiskyTokens?: boolean;
+ /** Time option used for request and local price-change sorting. */
+ timeOption?: TimeOption;
}
export interface UseTokensFeedResult {
@@ -31,7 +39,10 @@ export const useTokensFeed = ({
query,
refresh,
hideRiskyTokens = false,
+ timeOption,
}: UseTokensFeedOptions = {}): UseTokensFeedResult => {
+ const sortBy = timeOption ? mapTimeOptionToSortBy(timeOption) : undefined;
+
const {
data,
isLoading,
@@ -43,6 +54,14 @@ export const useTokensFeed = ({
} = useTrendingSearch({
searchQuery: query,
enableDebounce: false,
+ sortBy,
+ sortTrendingTokensOptions: timeOption
+ ? {
+ option: PriceChangeOption.PriceChange,
+ direction: SortDirection.Descending,
+ timeOption,
+ }
+ : undefined,
});
useFeedRefresh(refresh, refetch);
diff --git a/app/components/Views/TrendingView/tabs/CryptoTab.tsx b/app/components/Views/TrendingView/tabs/CryptoTab.tsx
index 89add723e79..55c57b1f4bc 100644
--- a/app/components/Views/TrendingView/tabs/CryptoTab.tsx
+++ b/app/components/Views/TrendingView/tabs/CryptoTab.tsx
@@ -23,7 +23,7 @@ import PerpsMarketTileCardSkeleton from '../../Homepage/Sections/Perpetuals/comp
import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation';
import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed';
import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection';
-import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation';
+import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
import CardList from '../components/CardList';
import ExploreScroll from '../components/ExploreScroll';
import SectionHeader from '../components/SectionHeader';
@@ -169,7 +169,7 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
title={strings('trending.predictions')}
testIdPrefix="predict-crypto-market-row-item"
idPrefix="crypto_predictions"
- onViewAll={() => navigateToPredictionsList(navigation, 'crypto')}
+ onViewAll={() => navigateToExplorePredictionsList(navigation, 'crypto')}
/>
);
diff --git a/app/components/Views/TrendingView/tabs/MacroTab.tsx b/app/components/Views/TrendingView/tabs/MacroTab.tsx
index 3a329b0549a..4c5cabab862 100644
--- a/app/components/Views/TrendingView/tabs/MacroTab.tsx
+++ b/app/components/Views/TrendingView/tabs/MacroTab.tsx
@@ -13,7 +13,7 @@ import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock';
import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation';
import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed';
import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection';
-import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation';
+import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
import ExploreScroll from '../components/ExploreScroll';
import type { PillToggleCardListTab } from '../components/PillToggleCardList';
import type { TabProps } from '../hooks/useExploreRefresh';
@@ -87,7 +87,9 @@ const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
title={strings('trending.predictions')}
testIdPrefix="predict-rwa-politics-market-row-item"
idPrefix="politics_predictions"
- onViewAll={() => navigateToPredictionsList(appNavigation, 'politics')}
+ onViewAll={() =>
+ navigateToExplorePredictionsList(appNavigation, 'politics')
+ }
isEnabled={isPredictEnabled}
/>
diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx
index fef2ddf490a..45c730e4dfe 100644
--- a/app/components/Views/TrendingView/tabs/NowTab.test.tsx
+++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx
@@ -20,6 +20,22 @@ jest.mock('../feeds/tokens/useTokensFeed', () => ({
useTokensFeed: jest.fn(() => ({ data: [], isLoading: false })),
}));
+jest.mock('../feeds/tokens/CryptoMoversPillItem', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { createElement } = require('react');
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { Text } = require('react-native');
+ return {
+ __esModule: true,
+ default: ({ token }: { token: { assetId: string; symbol: string } }) =>
+ createElement(
+ Text,
+ { testID: `section-pill-${token.assetId}` },
+ token.symbol,
+ ),
+ };
+});
+
const mockUseABTest = jest.fn();
jest.mock('../../../../hooks', () => ({
useABTest: (...args: unknown[]) =>
@@ -121,6 +137,8 @@ import { selectPredictEnabledFlag } from '../../../UI/Predict';
import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening';
import NowTab from './NowTab';
import type { RefreshConfig } from '../hooks/useExploreRefresh';
+import { useTokensFeed } from '../feeds/tokens/useTokensFeed';
+import Routes from '../../../../constants/navigation/Routes';
const defaultRefresh: RefreshConfig = { trigger: 0, silentRefresh: true };
const defaultTabProps = {
@@ -177,6 +195,10 @@ const mockControlAbTest = () =>
isActive: true,
});
+const mockUseTokensFeed = useTokensFeed as jest.MockedFunction<
+ typeof useTokensFeed
+>;
+
const mockTreatmentAbTest = () =>
mockUseABTest.mockReturnValue({
variant: { whatsHappeningBeforePredict: true },
@@ -362,3 +384,91 @@ describe('NowTab — Perps Movers "View All" navigation', () => {
expect(screen.queryByTestId('section-header-view-all-perps')).toBeNull();
});
});
+
+describe('NowTab — Crypto Movers', () => {
+ const mockUseSelector = useSelector as jest.MockedFunction<
+ typeof useSelector
+ >;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectPerpsEnabledFlag) return false;
+ if (selector === selectPredictEnabledFlag) return false;
+ if (selector === selectWhatsHappeningEnabled) return false;
+ return undefined;
+ });
+ mockControlAbTest();
+ mockUseTokensFeed.mockReturnValue({
+ data: [
+ {
+ assetId: 'eip155:1/erc20:0x1',
+ symbol: 'ONE',
+ priceChangePct: { h1: '1.23' },
+ },
+ {
+ assetId: 'eip155:1/erc20:0x2',
+ symbol: 'TWO',
+ priceChangePct: { h1: '2.34' },
+ },
+ {
+ assetId: 'eip155:1/erc20:0x3',
+ symbol: 'THREE',
+ priceChangePct: { h1: '3.45' },
+ },
+ ] as never,
+ isLoading: false,
+ refetch: jest.fn(),
+ });
+ });
+
+ it('requests and opens Crypto Movers with the 1h filter', () => {
+ renderNowTab();
+
+ expect(mockUseTokensFeed).toHaveBeenCalledWith({
+ refresh: defaultRefresh,
+ hideRiskyTokens: true,
+ timeOption: '1h',
+ });
+
+ fireEvent.press(
+ screen.getByTestId('section-header-view-all-crypto_movers'),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.WALLET.TRENDING_TOKENS_FULL_VIEW,
+ { initialTimeOption: '1h' },
+ );
+ });
+
+ it('renders Crypto Movers across three rows', () => {
+ renderNowTab();
+
+ expect(
+ screen.getByTestId('explore-crypto_movers-pills-list-row-0'),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId('explore-crypto_movers-pills-list-row-1'),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId('explore-crypto_movers-pills-list-row-2'),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders up to 18 Crypto Movers pills', () => {
+ mockUseTokensFeed.mockReturnValue({
+ data: Array.from({ length: 19 }, (_, index) => ({
+ assetId: `eip155:1/erc20:0x${index}`,
+ symbol: `T${index}`,
+ priceChangePct: { h1: String(index) },
+ })) as never,
+ isLoading: false,
+ refetch: jest.fn(),
+ });
+
+ renderNowTab();
+
+ expect(screen.getByTestId('section-pill-eip155:1/erc20:0x17')).toBeTruthy();
+ expect(screen.queryByTestId('section-pill-eip155:1/erc20:0x18')).toBeNull();
+ });
+});
diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx
index efcf104624f..e71a0aab52d 100644
--- a/app/components/Views/TrendingView/tabs/NowTab.tsx
+++ b/app/components/Views/TrendingView/tabs/NowTab.tsx
@@ -17,13 +17,14 @@ import { TokenRowItem } from '../feeds/tokens/TokenRowItem';
import CryptoMoversPillItem from '../feeds/tokens/CryptoMoversPillItem';
import CryptoMoversSkeleton from '../feeds/tokens/CryptoMoversSkeleton';
import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton';
+import { TimeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet';
import { usePerpsFeed, type PerpsFeedItem } from '../feeds/perps/usePerpsFeed';
import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider';
import PerpsPillItem from '../feeds/perps/PerpsPillItem';
import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation';
import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed';
import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection';
-import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation';
+import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
import { useStocksFeed } from '../feeds/stocks/useStocksFeed';
import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils';
import CardList from '../components/CardList';
@@ -98,6 +99,10 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => {
);
};
+const CRYPTO_MOVERS_TIME_OPTION = TimeOption.OneHour;
+const CRYPTO_MOVERS_ROW_COUNT = 3;
+const CRYPTO_MOVERS_MAX_PILLS = 18;
+
const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
const navigation = useNavigation();
const perpsNavigation =
@@ -118,7 +123,11 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
}, [refresh.trigger]);
const predictions = usePredictionsFeed({ refresh });
- const cryptoMovers = useTokensFeed({ refresh, hideRiskyTokens: true });
+ const cryptoMovers = useTokensFeed({
+ refresh,
+ hideRiskyTokens: true,
+ timeOption: CRYPTO_MOVERS_TIME_OPTION,
+ });
const stocks = useStocksFeed({ refresh });
const renderTokenItem: ListRenderItem = useCallback(
@@ -166,7 +175,7 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
title={strings('wallet.predict')}
testIdPrefix="predict-market-row-item"
idPrefix="predictions"
- onViewAll={() => navigateToPredictionsList(navigation, 'trending')}
+ onViewAll={() => navigateToExplorePredictionsList(navigation, 'trending')}
isEnabled={isPredictEnabled}
/>
);
@@ -189,7 +198,9 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
- navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW)
+ navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW, {
+ initialTimeOption: CRYPTO_MOVERS_TIME_OPTION,
+ })
}
testID="section-header-view-all-crypto_movers"
tabName="Now"
@@ -202,6 +213,7 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
trackExploreInteracted({
interaction_type: 'section_item_tapped',
@@ -219,6 +231,8 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
keyExtractor={(token) => token.assetId ?? ''}
Skeleton={CryptoMoversSkeleton}
listTestId="explore-crypto_movers-pills-list"
+ rowCount={CRYPTO_MOVERS_ROW_COUNT}
+ maxPills={CRYPTO_MOVERS_MAX_PILLS}
/>
)}
diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx
index f6818168ae9..c43807f49f2 100644
--- a/app/components/Views/TrendingView/tabs/RwasTab.tsx
+++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx
@@ -22,7 +22,7 @@ import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock';
import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation';
import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed';
import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection';
-import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation';
+import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
import CardList from '../components/CardList';
import ExploreScroll from '../components/ExploreScroll';
import type { PillToggleCardListTab } from '../components/PillToggleCardList';
@@ -135,7 +135,9 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
title={strings('trending.predictions')}
testIdPrefix="predict-rwa-politics-market-row-item"
idPrefix="politics_predictions"
- onViewAll={() => navigateToPredictionsList(appNavigation, 'politics')}
+ onViewAll={() =>
+ navigateToExplorePredictionsList(appNavigation, 'politics')
+ }
isEnabled={isPredictEnabled}
/>
diff --git a/app/components/Views/TrendingView/tabs/SportsTab.tsx b/app/components/Views/TrendingView/tabs/SportsTab.tsx
index 13226ae3e13..31f76d36908 100644
--- a/app/components/Views/TrendingView/tabs/SportsTab.tsx
+++ b/app/components/Views/TrendingView/tabs/SportsTab.tsx
@@ -31,7 +31,7 @@ import {
} from '../feeds/predictions/useSportsMarketsFeed';
import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection';
import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton';
-import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation';
+import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
import PillRow from '../components/PillRow';
import SectionHeader from '../components/SectionHeader';
import type { TabProps } from '../hooks/useExploreRefresh';
@@ -77,7 +77,7 @@ const SportsListHeader: React.FC = ({
title={strings('trending.predictions')}
testIdPrefix="predict-sports-market-row-item"
idPrefix="sports_predictions"
- onViewAll={() => navigateToPredictionsList(navigation, 'sports')}
+ onViewAll={() => navigateToExplorePredictionsList(navigation, 'sports')}
isEnabled={showSportsPredictions}
/>
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 164fe349a62..c73aca978b2 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -107,6 +107,7 @@ const Routes = {
REFERRAL_REWARDS_VIEW: 'ReferralRewardsView',
REWARDS_SETTINGS_VIEW: 'RewardsSettingsView',
REWARDS_DASHBOARD: 'RewardsDashboard',
+ REWARDS_VIP_SPLASH_VIEW: 'RewardsVipSplashView',
REWARDS_VIP_VIEW: 'RewardsVipView',
REWARDS_VIP_TIERS_VIEW: 'RewardsVipTiersView',
REWARDS_CAMPAIGNS_VIEW: 'RewardsCampaignsView',
diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts
index be841ca5249..2a270eab72f 100644
--- a/app/core/NavigationService/types.ts
+++ b/app/core/NavigationService/types.ts
@@ -55,6 +55,7 @@ import type { OnboardingInterestQuestionnaireRouteParams } from '../../component
// Perps navigation params
import type { PerpsNavigationParamList } from '../../components/UI/Perps/types/navigation';
+import type { TrendingTokensFullViewParams } from '../../components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView';
// QR Scanner params
import type { QRScannerParams } from '../../components/Views/QRScanner/QRScanner.types';
@@ -511,7 +512,7 @@ export interface RootStackParamList extends ParamListBase {
TokensFullView: undefined;
CashTokensFullView: undefined;
MoneyScreens: undefined;
- TrendingTokensFullView: undefined;
+ TrendingTokensFullView: TrendingTokensFullViewParams | undefined;
RWATokensFullView: undefined;
// Vault recovery routes
diff --git a/app/images/rewards/hypertracker.svg b/app/images/rewards/hypertracker.svg
new file mode 100644
index 00000000000..56cc433f269
--- /dev/null
+++ b/app/images/rewards/hypertracker.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/images/rewards/vip_splash.png b/app/images/rewards/vip_splash.png
new file mode 100644
index 00000000000..71dad487f30
Binary files /dev/null and b/app/images/rewards/vip_splash.png differ
diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts
index 2b0fad660fb..30af887141f 100644
--- a/app/reducers/rewards/index.test.ts
+++ b/app/reducers/rewards/index.test.ts
@@ -30,6 +30,7 @@ import rewardsReducer, {
setVipDashboard,
setVipDashboardError,
setVipDashboardLoading,
+ acceptVipInvite,
setCampaigns,
setCampaignsLoading,
setCampaignsError,
@@ -2070,6 +2071,9 @@ describe('rewardsReducer', () => {
vipDashboard: {},
vipDashboardLoading: false,
vipDashboardError: false,
+ vipSplashAccepted: {
+ 'sub-1': true,
+ },
campaigns: [],
campaignsLoading: false,
campaignsError: false,
@@ -2201,6 +2205,7 @@ describe('rewardsReducer', () => {
vipDashboard: {},
vipDashboardLoading: false,
vipDashboardError: false,
+ vipSplashAccepted: {},
campaigns: [],
campaignsLoading: false,
campaignsError: false,
@@ -4912,6 +4917,36 @@ describe('setVipDashboardError', () => {
});
});
+describe('acceptVipInvite', () => {
+ it('marks the VIP invite as accepted for a subscription', () => {
+ const state = rewardsReducer(
+ initialState,
+ acceptVipInvite({ subscriptionId: 'sub-1' }),
+ );
+
+ expect(state.vipSplashAccepted['sub-1']).toBe(true);
+ });
+
+ it('preserves existing accepted VIP invites for other subscriptions', () => {
+ const stateWithAcceptedInvite: RewardsState = {
+ ...initialState,
+ vipSplashAccepted: {
+ 'sub-1': true,
+ },
+ };
+
+ const state = rewardsReducer(
+ stateWithAcceptedInvite,
+ acceptVipInvite({ subscriptionId: 'sub-2' }),
+ );
+
+ expect(state.vipSplashAccepted).toEqual({
+ 'sub-1': true,
+ 'sub-2': true,
+ });
+ });
+});
+
const mockCampaign: CampaignDto = {
id: 'campaign-1',
type: 'ONDO_HOLDING' as CampaignType,
@@ -6145,6 +6180,36 @@ describe('ondoCampaignDeposits', () => {
});
});
+ describe('persist/REHYDRATE — vipSplashAccepted', () => {
+ it('restores accepted VIP invite state from persisted rewards state', () => {
+ const persisted: RewardsState = {
+ ...initialState,
+ vipSplashAccepted: {
+ 'sub-1': true,
+ },
+ };
+
+ const state = rewardsReducer(initialState, {
+ type: 'persist/REHYDRATE',
+ payload: { rewards: persisted },
+ });
+
+ expect(state.vipSplashAccepted).toEqual({ 'sub-1': true });
+ });
+
+ it('defaults to empty object when vipSplashAccepted is absent from persisted state', () => {
+ const persisted = { ...initialState } as Partial;
+ delete persisted.vipSplashAccepted;
+
+ const state = rewardsReducer(initialState, {
+ type: 'persist/REHYDRATE',
+ payload: { rewards: persisted },
+ });
+
+ expect(state.vipSplashAccepted).toEqual({});
+ });
+ });
+
describe('setCandidateSubscriptionId — preserves dismissedCampaignOutcomeToasts', () => {
it('preserves dismissedCampaignOutcomeToasts when subscription ID changes', () => {
const stateWithDismissals: RewardsState = {
@@ -6165,4 +6230,25 @@ describe('ondoCampaignDeposits', () => {
});
});
});
+
+ describe('setCandidateSubscriptionId — preserves vipSplashAccepted', () => {
+ it('preserves accepted VIP invites when subscription ID changes', () => {
+ const stateWithAcceptedInvite: RewardsState = {
+ ...initialState,
+ candidateSubscriptionId: 'old-sub',
+ vipSplashAccepted: {
+ 'old-sub': true,
+ },
+ };
+
+ const state = rewardsReducer(
+ stateWithAcceptedInvite,
+ setCandidateSubscriptionId('new-sub'),
+ );
+
+ expect(state.vipSplashAccepted).toEqual({
+ 'old-sub': true,
+ });
+ });
+ });
});
diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts
index 7afaf25384e..ca7126631f0 100644
--- a/app/reducers/rewards/index.ts
+++ b/app/reducers/rewards/index.ts
@@ -134,6 +134,7 @@ export interface RewardsState {
vipDashboard: Record;
vipDashboardLoading: boolean;
vipDashboardError: boolean;
+ vipSplashAccepted: Record;
// Campaigns state
campaigns: CampaignDto[];
@@ -271,6 +272,7 @@ export const initialState: RewardsState = {
vipDashboard: {},
vipDashboardLoading: false,
vipDashboardError: false,
+ vipSplashAccepted: {},
// Campaigns initial state
campaigns: [],
@@ -430,6 +432,7 @@ const rewardsSlice = createSlice({
state.vipDashboard = {};
state.vipDashboardLoading = false;
state.vipDashboardError = false;
+ state.vipSplashAccepted = {};
},
setOnboardingActiveStep: (state, action: PayloadAction) => {
@@ -480,6 +483,7 @@ const rewardsSlice = createSlice({
hideUnlinkedAccountsBanner: state.hideUnlinkedAccountsBanner,
bulkLink: state.bulkLink,
dismissedCampaignOutcomeToasts: state.dismissedCampaignOutcomeToasts,
+ vipSplashAccepted: state.vipSplashAccepted,
versionGuardMinimumMobileVersion:
state.versionGuardMinimumMobileVersion,
versionGuardLoading: state.versionGuardLoading,
@@ -732,6 +736,13 @@ const rewardsSlice = createSlice({
state.vipDashboardError = action.payload;
},
+ acceptVipInvite: (
+ state,
+ action: PayloadAction<{ subscriptionId: string }>,
+ ) => {
+ state.vipSplashAccepted[action.payload.subscriptionId] = true;
+ },
+
setOndoCampaignActivity: (
state,
action: PayloadAction<{
@@ -947,6 +958,7 @@ const rewardsSlice = createSlice({
unlockedRewards: action.payload.rewards.unlockedRewards,
campaigns: action.payload.rewards.campaigns ?? [],
vipDashboard: action.payload.rewards.vipDashboard ?? {},
+ vipSplashAccepted: action.payload.rewards.vipSplashAccepted ?? {},
campaignParticipantStatuses:
action.payload.rewards.campaignParticipantStatuses ?? {},
ondoCampaignLeaderboardPositions:
@@ -1019,6 +1031,7 @@ export const {
setVipDashboard,
setVipDashboardError,
setVipDashboardLoading,
+ acceptVipInvite,
// Campaigns actions
setCampaigns,
setCampaignsLoading,
diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts
index a2ce06a3239..87058efabb3 100644
--- a/app/reducers/rewards/selectors.test.ts
+++ b/app/reducers/rewards/selectors.test.ts
@@ -49,6 +49,7 @@ import {
selectVipDashboard,
selectVipDashboardError,
selectVipDashboardLoading,
+ selectHasAcceptedVipInvite,
selectCampaigns,
selectCampaignsLoading,
selectCampaignsError,
@@ -3272,6 +3273,30 @@ describe('Rewards selectors', () => {
expect(selectVipDashboardError(state)).toBe(true);
});
+
+ it('returns false when VIP invite acceptance has no subscription id', () => {
+ const state = createMockRootState({
+ vipSplashAccepted: { 'sub-1': true },
+ });
+
+ expect(selectHasAcceptedVipInvite(null)(state)).toBe(false);
+ });
+
+ it('returns false when VIP invite has not been accepted for subscription', () => {
+ const state = createMockRootState({
+ vipSplashAccepted: { 'sub-2': true },
+ });
+
+ expect(selectHasAcceptedVipInvite('sub-1')(state)).toBe(false);
+ });
+
+ it('returns true when VIP invite has been accepted for subscription', () => {
+ const state = createMockRootState({
+ vipSplashAccepted: { 'sub-1': true },
+ });
+
+ expect(selectHasAcceptedVipInvite('sub-1')(state)).toBe(true);
+ });
});
describe('selectCampaigns', () => {
diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts
index 8d71bb0527a..75c959b859a 100644
--- a/app/reducers/rewards/selectors.ts
+++ b/app/reducers/rewards/selectors.ts
@@ -178,6 +178,13 @@ export const selectVipDashboardLoading = (state: RootState): boolean =>
export const selectVipDashboardError = (state: RootState): boolean =>
state.rewards.vipDashboardError;
+export const selectHasAcceptedVipInvite =
+ (subscriptionId: string | null | undefined) =>
+ (state: RootState): boolean =>
+ subscriptionId
+ ? state.rewards.vipSplashAccepted?.[subscriptionId] === true
+ : false;
+
// Campaigns selectors
export const selectCampaigns = (
state: RootState,
diff --git a/locales/languages/en.json b/locales/languages/en.json
index cc789ba1818..5588603e874 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -8635,6 +8635,10 @@
"points_from_referrals_label": "Points from referrals",
"referrals_label": "VIP Referrals",
"tier_benefits_title": "Tier benefits",
+ "splash_title": "WELCOME\nTO GOLD\nFOX VIP",
+ "splash_description": "Unlock exclusive perks, early features, and curated rewards. By invitation only.",
+ "splash_accept_invite": "Accept invite",
+ "splash_not_now": "Not now",
"next_tier_value": "{{value}} next tier",
"equity_rebate_label": "Equity rebate",
"equity_rebate_header": "Equity rebate: {{value}}%",