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}}%",