From e0cb4d3ec7418cb300d6496be0dd41c86627a4ab Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 3 Feb 2026 00:39:47 -0700 Subject: [PATCH 01/18] fix: add missing prop to fix TokenListItem test (#25559) ## **Description** TokenListItem tests are failing. https://github.com/MetaMask/metamask-mobile/actions/runs/21614511374/job/62290254653?pr=25557 This is a fix. ``` Error: app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx(1043,10): error TS2741: Property 'shouldShowTokenListItemCta' is missing in type '{ assetKey: FlashListAssetKey; showRemoveMenu: Mock; setShowScamWarningModal: Mock; privacyMode: false; }' but required in type 'TokenListItemProps'. Error: app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx(1070,10): error TS2741: Property 'shouldShowTokenListItemCta' is missing in type '{ assetKey: FlashListAssetKey; showRemoveMenu: Mock; setShowScamWarningModal: Mock; privacyMode: false; }' but required in type 'TokenListItemProps'. Error: Process completed with exit code 2. ``` ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: test-only change that wires a newly-required prop into `TokenListItem` render calls, with no runtime behavior impact. > > **Overview** > Fixes failing `TokenListItem` unit tests by passing the required `shouldShowTokenListItemCta` prop in the stablecoin lending CTA scenarios, aligning the test renders with the updated `TokenListItemProps` type. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 571ac1c0213089eb6693a79ae1e8c4756605ae35. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index b1bbcb3f64e..e85b76d6f46 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -1044,6 +1044,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { assetKey={assetKey} showRemoveMenu={jest.fn()} setShowScamWarningModal={jest.fn()} + shouldShowTokenListItemCta={mockShouldShowTokenListItemCta} privacyMode={false} />, ); @@ -1071,6 +1072,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { assetKey={assetKey} showRemoveMenu={jest.fn()} setShowScamWarningModal={jest.fn()} + shouldShowTokenListItemCta={mockShouldShowTokenListItemCta} privacyMode={false} />, ); From e6e0330d574b0b3b2fbada7aca1560f529bc7d6b Mon Sep 17 00:00:00 2001 From: VGR Date: Tue, 3 Feb 2026 10:05:22 +0100 Subject: [PATCH 02/18] feat: reward season 2 status banner (#25522) ## **Description** Prepping for season 2 by reworking the season status banner. In figma this is the design: image ## **Changelog** CHANGELOG entry: add rewards season 2 season status banner ## **Screenshots/Recordings** ### **After** Loading image Loaded image Error image --- > [!NOTE] > **Medium Risk** > Medium risk because it rewrites the `SeasonStatus` UI and its loading/error rendering paths, which can easily introduce layout/regression issues and incorrect state handling. No auth or sensitive data logic is changed. > > **Overview** > Updates the Rewards dashboard to render a redesigned `SeasonStatus` banner with horizontal layout, themed background, and season name + time remaining on the right. > > Reworks `SeasonStatus` to remove tier/progress/level-up UI and the image modal, simplify loading to a smaller skeleton, and keep the error banner with retry calling `fetchSeasonStatus`. > > Adds/updates unit tests to match the new banner behavior and introduces a new i18n string `rewards.season_status.points_earned` while removing old season-status copy (`rewards.points`, `rewards.point`, `rewards.to_level_up`) from `en.json`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b6a1eabeefd29c3c3afd7ddcb7caba574b510a03. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Opus 4.5 --- .../UI/Rewards/Views/RewardsDashboard.tsx | 4 +- .../SeasonStatus/SeasonStatus.test.tsx | 1322 +++++++---------- .../components/SeasonStatus/SeasonStatus.tsx | 284 +--- locales/languages/en.json | 6 +- 4 files changed, 634 insertions(+), 982 deletions(-) diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index 47d242b53cc..e421f2c1e1a 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -328,7 +328,9 @@ const RewardsDashboard: React.FC = () => { ) : ( <> - + + + {/* Tab View */} diff --git a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx index d1927ad9dbe..04188a95979 100644 --- a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx +++ b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx @@ -1,133 +1,55 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; -import { useSelector, useDispatch } from 'react-redux'; -import SeasonStatus from './SeasonStatus'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import SeasonStatusSummary from './SeasonStatus'; +import { + selectSeasonStatusLoading, + selectBalanceTotal, + selectSeasonEndDate, + selectSeasonName, + selectSeasonStatusError, + selectSeasonStartDate, +} from '../../../../../reducers/rewards/selectors'; +import { formatNumber, formatTimeRemaining } from '../../utils/formatUtils'; // Mock react-redux jest.mock('react-redux', () => ({ useSelector: jest.fn(), - useDispatch: jest.fn(), })); const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseDispatch = useDispatch as jest.MockedFunction; -// Mock individual selectors +// Mock selectors jest.mock('../../../../../reducers/rewards/selectors', () => ({ selectSeasonStatusLoading: jest.fn(), - selectSeasonTiers: jest.fn(), selectBalanceTotal: jest.fn(), selectSeasonEndDate: jest.fn(), - selectSeasonStartDate: jest.fn(), - selectNextTierPointsNeeded: jest.fn(), - selectCurrentTier: jest.fn(), - selectNextTier: jest.fn(), + selectSeasonName: jest.fn(), selectSeasonStatusError: jest.fn(), + selectSeasonStartDate: jest.fn(), })); -// Import the mocked selectors -import { - selectSeasonStatusLoading, - selectSeasonTiers, - selectBalanceTotal, - selectSeasonEndDate, - selectSeasonStartDate, - selectNextTierPointsNeeded, - selectCurrentTier, - selectSeasonStatusError, - selectNextTier, -} from '../../../../../reducers/rewards/selectors'; - -// Mock useSeasonStatus hook -jest.mock('../../hooks/useSeasonStatus', () => ({ - useSeasonStatus: jest.fn(), -})); - -import { useSeasonStatus } from '../../hooks/useSeasonStatus'; - -// Mock fallback tier image -jest.mock( - '../../../../../images/rewards/tiers/rewards-s1-tier-1.png', - () => 'fallback-tier-image', -); - -const mockSelectSeasonStatusLoading = - selectSeasonStatusLoading as jest.MockedFunction< - typeof selectSeasonStatusLoading - >; -const mockSelectSeasonTiers = selectSeasonTiers as jest.MockedFunction< - typeof selectSeasonTiers ->; -const mockSelectBalanceTotal = selectBalanceTotal as jest.MockedFunction< - typeof selectBalanceTotal ->; -const mockSelectSeasonEndDate = selectSeasonEndDate as jest.MockedFunction< - typeof selectSeasonEndDate ->; -const mockSelectSeasonStartDate = selectSeasonStartDate as jest.MockedFunction< - typeof selectSeasonStartDate ->; -const mockSelectNextTierPointsNeeded = - selectNextTierPointsNeeded as jest.MockedFunction< - typeof selectNextTierPointsNeeded - >; -const mockSelectCurrentTier = selectCurrentTier as jest.MockedFunction< - typeof selectCurrentTier ->; -const mockSelectNextTier = selectNextTier as jest.MockedFunction< - typeof selectNextTier ->; -const mockSelectSeasonStatusError = - selectSeasonStatusError as jest.MockedFunction< - typeof selectSeasonStatusError - >; -const mockUseSeasonStatus = useSeasonStatus as jest.MockedFunction< - typeof useSeasonStatus ->; - -// Mock useTailwind hook -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => { - const mockTw = jest.fn(() => ({})); - // Add the style method to the mock function - Object.assign(mockTw, { - style: jest.fn((styles) => { - if (Array.isArray(styles)) { - return styles.reduce((acc, style) => ({ ...acc, ...style }), {}); - } - return styles || {}; - }), - }); - return mockTw; - }, -})); - -// Mock format utilities +// Mock formatUtils jest.mock('../../utils/formatUtils', () => ({ - formatNumber: jest.fn((value) => value?.toLocaleString() || '0'), - formatTimeRemaining: jest.fn(() => '15d 10h'), + formatNumber: jest.fn((value: number | null) => + value === null || value === undefined ? '0' : value.toLocaleString(), + ), + formatTimeRemaining: jest.fn((endDate: Date) => { + const now = new Date('2024-06-15T12:00:00.000Z'); + const diff = endDate.getTime() - now.getTime(); + if (diff <= 0) return null; + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + return `${days}d ${hours}h ${minutes}m`; + }), })); -import { formatNumber, formatTimeRemaining } from '../../utils/formatUtils'; -const mockFormatNumber = formatNumber as jest.MockedFunction< - typeof formatNumber ->; -const mockFormatTimeRemaining = formatTimeRemaining as jest.MockedFunction< - typeof formatTimeRemaining ->; - -// Import types -import { SeasonTierDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; - // Mock i18n jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { const translations: Record = { - 'rewards.level': 'Level', - 'rewards.season_ends': 'Season ends', - 'rewards.points': 'Points', - 'rewards.point': 'Point', - 'rewards.to_level_up': 'to level up', + 'rewards.season_status.points_earned': 'Points earned', 'rewards.season_status_error.error_fetching_title': "Season balance couldn't be loaded", 'rewards.season_status_error.error_fetching_description': @@ -136,830 +58,708 @@ jest.mock('../../../../../../locales/i18n', () => ({ }; return translations[key] || key; }), - default: { - locale: 'en', - }, })); -// Mock theme +// Mock useSeasonStatus hook +const mockFetchSeasonStatus = jest.fn(); +jest.mock('../../hooks/useSeasonStatus', () => ({ + useSeasonStatus: () => ({ + fetchSeasonStatus: mockFetchSeasonStatus, + }), +})); + +// Mock useTheme jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ + useTheme: jest.fn(() => ({ colors: { - accent01: { normal: '#0052FF' }, - background: { section: '#F2F4F6' }, + background: { + alternative: '#f5f5f5', + default: '#ffffff', + section: '#f9f9f9', + }, + text: { + primary: '#000000', + alternative: '#666666', + }, }, - }), + })), })); -// Mock ProgressBar -jest.mock('react-native-progress/Bar', () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return ReactActual.forwardRef((props: { testID?: string }, ref: unknown) => - ReactActual.createElement(View, { - testID: props.testID || 'progress-bar', - ref, - ...props, - }), - ); -}); - -// Mock SVG component -jest.mock('../../../../../images/rewards/metamask-rewards-points.svg', () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return ReactActual.forwardRef( - (props: Record, ref: unknown) => - ReactActual.createElement(View, { - testID: 'metamask-rewards-points-svg', - ref, - ...props, +// Mock Tailwind +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const mockTw = jest.fn(() => ({})); + Object.assign(mockTw, { + style: jest.fn((styles) => { + if (typeof styles === 'object') { + return styles; + } + if (Array.isArray(styles)) { + return styles.reduce( + (acc, style) => ({ ...acc, ...style }), + {} as Record, + ); + } + return {}; }), - ); -}); - -// Mock Skeleton -jest.mock('../../../../../component-library/components/Skeleton', () => ({ - Skeleton: ({ testID, ...props }: { testID?: string }) => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return ReactActual.createElement(View, { - testID: testID || 'skeleton', - ...props, }); + return mockTw; }, })); -// Mock RewardsErrorBanner component -jest.mock('../RewardsErrorBanner', () => { +// Mock design system components +jest.mock('@metamask/design-system-react-native', () => { const ReactActual = jest.requireActual('react'); - const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ReactActual.forwardRef( - ( - { - title, - description, - onConfirm, - confirmButtonLabel, - testID, - ...props - }: { - title?: string; - description?: string; - onConfirm?: () => void; - confirmButtonLabel?: string; - testID?: string; - }, - ref: unknown, - ) => - ReactActual.createElement( - View, - { - testID: testID || 'rewards-error-banner', - ref, - ...props, - }, - [ - ReactActual.createElement(Text, { key: 'title' }, title), - ReactActual.createElement( - Text, - { key: 'description' }, - description, - ), - onConfirm && - ReactActual.createElement( - TouchableOpacity, - { key: 'confirm', onPress: onConfirm }, - ReactActual.createElement( - Text, - {}, - confirmButtonLabel || 'Confirm', - ), - ), - ], - ), - ), - }; -}); - -// Mock setSeasonStatusError action -jest.mock('../../../../../actions/rewards', () => ({ - setSeasonStatusError: jest.fn((payload) => ({ - type: 'rewards/setSeasonStatusError', - payload, - })), -})); + const { View, Text: RNText } = jest.requireActual('react-native'); + + const Box = ({ + children, + testID, + style, + ...props + }: { + children?: React.ReactNode; + testID?: string; + style?: Record; + [key: string]: unknown; + }) => ReactActual.createElement(View, { testID, style, ...props }, children); + + const TextComponent = ({ + children, + testID, + ...props + }: { + children?: React.ReactNode; + testID?: string; + [key: string]: unknown; + }) => ReactActual.createElement(RNText, { testID, ...props }, children); -// Mock lodash capitalize but preserve the rest of lodash -jest.mock('lodash', () => { - const actual = jest.requireActual('lodash'); return { - ...actual, - capitalize: jest.fn((str) => str?.charAt(0).toUpperCase() + str?.slice(1)), + Box, + Text: TextComponent, + TextVariant: { + HeadingMd: 'HeadingMd', + BodyMd: 'BodyMd', + BodySm: 'BodySm', + }, + FontWeight: { + Bold: 'bold', + Medium: 'medium', + }, + BoxFlexDirection: { + Row: 'row', + Column: 'column', + }, + BoxAlignItems: { + Center: 'center', + FlexEnd: 'flex-end', + }, + BoxJustifyContent: { + SpaceBetween: 'space-between', + }, }; }); -// Mock RewardsThemeImageComponent -jest.mock('../ThemeImageComponent', () => { +// Mock SVG image +jest.mock('../../../../../images/rewards/metamask-rewards-points.svg', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ReactActual.forwardRef( - ( - props: { - themeImage?: { lightModeUrl?: string; darkModeUrl?: string }; - style?: unknown; - testID?: string; - }, - ref: unknown, - ) => - ReactActual.createElement(View, { - testID: props.testID || 'season-tier-image', - 'data-light-mode-url': props.themeImage?.lightModeUrl, - 'data-dark-mode-url': props.themeImage?.darkModeUrl, - style: props.style, - ref, - }), - ), + return function MockSvg(props: Record) { + return ReactActual.createElement(View, { + testID: 'metamask-rewards-points-image', + ...props, + }); }; }); -// Mock RewardsImageModal -jest.mock('../RewardsImageModal', () => { +// Mock Skeleton component +jest.mock('../../../../../component-library/components/Skeleton', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { - __esModule: true, - default: ReactActual.forwardRef( - ( - { - visible, - onClose, - themeImage, - fallbackImage, - testID, - ...props - }: { - visible?: boolean; - onClose?: () => void; - themeImage?: { lightModeUrl?: string; darkModeUrl?: string }; - fallbackImage?: unknown; - testID?: string; - }, - ref: unknown, - ) => - ReactActual.createElement(View, { - testID: testID || 'rewards-image-modal', - 'data-visible': visible, - 'data-on-close': onClose, - 'data-theme-image': themeImage, - 'data-fallback-image': fallbackImage, - ref, - ...props, - }), - ), + Skeleton: ({ height, width }: { height: number; width: string }) => + ReactActual.createElement(View, { + testID: 'skeleton-loader', + style: { height, width }, + }), }; }); -describe('SeasonStatus', () => { - const mockDispatch = jest.fn(); - - // Default mock values - const defaultMockValues = { - seasonStatusLoading: false, - seasonStatusError: null, - seasonStartDate: new Date('2024-01-01T00:00:00Z'), - seasonEndDate: new Date('2024-12-31T23:59:59Z'), - balanceTotal: 1500, - currentTier: { - id: 'bronze', - name: 'bronze', - pointsNeeded: 0, - image: { - lightModeUrl: 'lightModeUrl', - darkModeUrl: 'darkModeUrl', - }, - levelNumber: 'Level 1', - rewards: [], - }, - nextTier: { - id: 'silver', - name: 'silver', - pointsNeeded: 2000, - image: { - lightModeUrl: 'lightModeUrl', - darkModeUrl: 'darkModeUrl', - }, - levelNumber: 'Level 2', - rewards: [], - }, - nextTierPointsNeeded: 500, - seasonTiers: [ - { - id: 'bronze', - name: 'bronze', - pointsNeeded: 0, - image: { - lightModeUrl: 'lightModeUrl', - darkModeUrl: 'darkModeUrl', - }, - levelNumber: 'Level 1', - rewards: [], - }, - { - id: 'silver', - name: 'silver', - pointsNeeded: 2000, - image: { - lightModeUrl: 'lightModeUrl', - darkModeUrl: 'darkModeUrl', - }, - levelNumber: 'Level 2', - rewards: [], - }, - { - id: 'gold', - name: 'gold', - pointsNeeded: 5000, - image: { - lightModeUrl: 'lightModeUrl', - darkModeUrl: 'darkModeUrl', - }, - levelNumber: 'Level 3', - rewards: [], - }, - ], - }; +// Mock RewardsErrorBanner +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText, Pressable } = jest.requireActual('react-native'); + const RewardsErrorBanner = ({ + title, + description, + onConfirm, + confirmButtonLabel, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + }) => + ReactActual.createElement( + View, + { testID: 'rewards-error-banner' }, + ReactActual.createElement(RNText, { testID: 'error-title' }, title), + ReactActual.createElement( + RNText, + { testID: 'error-description' }, + description, + ), + onConfirm && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: 'error-retry-button' }, + ReactActual.createElement( + RNText, + null, + confirmButtonLabel || 'Confirm', + ), + ), + ); + return RewardsErrorBanner; +}); + +describe('SeasonStatusSummary', () => { + const mockFormatNumber = formatNumber as jest.MockedFunction< + typeof formatNumber + >; + const mockFormatTimeRemaining = formatTimeRemaining as jest.MockedFunction< + typeof formatTimeRemaining + >; beforeEach(() => { jest.clearAllMocks(); - - // Reset format utilities to default behavior - mockFormatNumber.mockImplementation( - (value) => value?.toLocaleString() || '0', - ); - mockFormatTimeRemaining.mockImplementation(() => '15d 10h'); - - // Setup default mock returns - mockUseDispatch.mockReturnValue(mockDispatch); - mockUseSeasonStatus.mockImplementation(() => ({ - fetchSeasonStatus: jest.fn(), - })); - mockSelectSeasonStatusLoading.mockReturnValue( - defaultMockValues.seasonStatusLoading, - ); - mockSelectSeasonStatusError.mockReturnValue( - defaultMockValues.seasonStatusError, - ); - mockSelectSeasonStartDate.mockReturnValue( - defaultMockValues.seasonStartDate, - ); - mockSelectSeasonEndDate.mockReturnValue(defaultMockValues.seasonEndDate); - mockSelectBalanceTotal.mockReturnValue(defaultMockValues.balanceTotal); - mockSelectCurrentTier.mockReturnValue(defaultMockValues.currentTier); - mockSelectNextTier.mockReturnValue(defaultMockValues.nextTier); - mockSelectNextTierPointsNeeded.mockReturnValue( - defaultMockValues.nextTierPointsNeeded, - ); - mockSelectSeasonTiers.mockReturnValue(defaultMockValues.seasonTiers); - - // Setup useSelector to call the appropriate selector function - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => selector({} as unknown), + mockFetchSeasonStatus.mockClear(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-06-15T12:00:00.000Z')); + + // Default mock implementation + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); + + // Reset format mocks + mockFormatNumber.mockImplementation((value: number | null) => + value === null || value === undefined ? '0' : value.toLocaleString(), ); + mockFormatTimeRemaining.mockImplementation(() => '10d 5h 30m'); }); - describe('Loading State', () => { - it('should render skeleton when seasonStatusLoading is true', () => { - mockSelectSeasonStatusLoading.mockReturnValue(true); - - const { getByTestId, queryByText } = render(); - - expect(getByTestId('skeleton')).toBeTruthy(); - expect(queryByText('Level')).toBeNull(); - }); + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); }); - describe('Basic Rendering', () => { - it('should render all main components when data is available', () => { - const { getByText, getByTestId } = render(); - - expect(getByText('Level 1')).toBeTruthy(); - expect(getByText('Bronze')).toBeTruthy(); - expect(getByText('Season ends')).toBeTruthy(); - expect(getByText('15d 10h')).toBeTruthy(); - expect(getByText('1,500')).toBeTruthy(); - expect(getByText('points')).toBeTruthy(); - expect(getByText('500 to level up')).toBeTruthy(); - expect(getByTestId('season-tier-image')).toBeTruthy(); - expect(getByTestId('metamask-rewards-points-svg')).toBeTruthy(); - }); - - it('should capitalize tier names correctly', () => { - mockSelectCurrentTier.mockReturnValue({ - id: 'gold', - name: 'gold', - pointsNeeded: 5000, - image: { - lightModeUrl: 'lightModeUrl', - darkModeUrl: 'darkModeUrl', - }, - levelNumber: 'Level 3', - rewards: [], - }); - - const { getByText } = render(); + describe('useSelector calls', () => { + it('calls selectSeasonStatusLoading selector', () => { + render(); - expect(getByText('Gold')).toBeTruthy(); + expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStatusLoading); }); - }); - describe('Progress Calculation', () => { - it('should display progress bars when user has partial progress', () => { - // Given: user has 1500 points, needs 2000 for next tier (75% progress) - mockSelectBalanceTotal.mockReturnValue(1500); - mockSelectNextTier.mockReturnValue({ - id: 'silver', - name: 'silver', - pointsNeeded: 2000, - image: { - lightModeUrl: 'lightModeUrl', - darkModeUrl: 'darkModeUrl', - }, - levelNumber: 'Level 2', - rewards: [], - }); + it('calls selectSeasonStatusError selector', () => { + render(); - // When: component renders - const { getAllByTestId } = render(); - - // Then: progress bars are displayed - const progressBars = getAllByTestId('progress-bar'); - expect(progressBars).toBeTruthy(); - expect(progressBars.length).toBeGreaterThan(0); + expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStatusError); }); - it('should show points needed when not at max tier', () => { - // Given: user needs 500 more points for next tier - mockSelectNextTierPointsNeeded.mockReturnValue(500); - - // When: component renders - const { getByText } = render(); + it('calls selectSeasonStartDate selector', () => { + render(); - // Then: points needed is displayed - expect(getByText('500 to level up')).toBeTruthy(); + expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStartDate); }); - it('should hide points needed when at max tier', () => { - // Given: user is at max tier (no next tier) - mockSelectNextTier.mockReturnValue(null); - mockSelectNextTierPointsNeeded.mockReturnValue(null); - - // When: component renders - const { queryByText } = render(); + it('calls selectSeasonEndDate selector', () => { + render(); - // Then: "to level up" text is not shown - expect(queryByText('to level up')).toBeNull(); + expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonEndDate); }); - }); - - describe('Time Remaining Formatting', () => { - it('should display time remaining in days and hours format', () => { - mockFormatTimeRemaining.mockReturnValue('15d 10h'); - const { getByText } = render(); + it('calls selectSeasonName selector', () => { + render(); - expect(getByText('15d 10h')).toBeTruthy(); + expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonName); }); - it('should display only minutes when hours is 0 but minutes > 0', () => { - mockFormatTimeRemaining.mockReturnValue('45m'); - - const { getByText } = render(); + it('calls selectBalanceTotal selector', () => { + render(); - expect(getByText('45m')).toBeTruthy(); + expect(mockUseSelector).toHaveBeenCalledWith(selectBalanceTotal); }); + }); - it('should not display time remaining when formatTimeRemaining returns empty string', () => { - mockFormatTimeRemaining.mockReturnValue(''); + describe('loading state', () => { + it('renders skeleton loader when loading', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return true; + return undefined; + }); - const { queryByText } = render(); + const { getByTestId } = render(); - expect(queryByText('Season ends')).toBeNull(); + expect(getByTestId('skeleton-loader')).toBeOnTheScreen(); }); - it('should not display time remaining when seasonEndDate is null', () => { - mockSelectSeasonEndDate.mockReturnValue(null); + it('does not render points image when loading', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return true; + return undefined; + }); - const { queryByText } = render(); + const { queryByTestId } = render(); - expect(queryByText('Season ends')).toBeNull(); + expect(queryByTestId('metamask-rewards-points-image')).toBeNull(); }); - it('should call formatTimeRemaining with correct date when seasonEndDate is available', () => { - const endDate = new Date('2024-12-31T23:59:59Z'); - mockSelectSeasonEndDate.mockReturnValue(endDate); + it('does not render season name when loading', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return true; + if (selector === selectSeasonName) return 'Season 1'; + return undefined; + }); - render(); + const { queryByText } = render(); - expect(mockFormatTimeRemaining).toHaveBeenCalledWith(endDate); + expect(queryByText('Season 1')).toBeNull(); }); }); - describe('Points Formatting and Pluralization', () => { - it('should display formatted points with proper pluralization for multiple points', () => { - mockSelectBalanceTotal.mockReturnValue(1500); - mockFormatNumber.mockReturnValue('1,500'); + describe('error state', () => { + it('renders error banner when error exists and no season start date', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return true; + if (selector === selectSeasonStartDate) return null; + return undefined; + }); - const { getByText } = render(); + const { getByTestId } = render(); - expect(getByText('1,500')).toBeTruthy(); - expect(getByText('points')).toBeTruthy(); + expect(getByTestId('rewards-error-banner')).toBeOnTheScreen(); }); - it('should display singular "point" for single point', () => { - mockSelectBalanceTotal.mockReturnValue(1); - mockFormatNumber.mockReturnValue('1'); + it('renders error title in error banner', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return true; + if (selector === selectSeasonStartDate) return null; + return undefined; + }); - const { getByText } = render(); + const { getByTestId } = render(); - expect(getByText('1')).toBeTruthy(); - expect(getByText('point')).toBeTruthy(); + expect(getByTestId('error-title')).toBeOnTheScreen(); }); - it('should display "0 points" when balance is null', () => { - mockSelectBalanceTotal.mockReturnValue(null); - mockFormatNumber.mockReturnValue('0'); + it('renders error description in error banner', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return true; + if (selector === selectSeasonStartDate) return null; + return undefined; + }); - const { getByText } = render(); + const { getByTestId } = render(); - expect(getByText('0')).toBeTruthy(); - expect(getByText('points')).toBeTruthy(); + expect(getByTestId('error-description')).toBeOnTheScreen(); }); - it('should display "0 points" when balance is undefined', () => { - mockSelectBalanceTotal.mockReturnValue(null); - mockFormatNumber.mockReturnValue('0'); + it('renders retry button in error banner', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return true; + if (selector === selectSeasonStartDate) return null; + return undefined; + }); - const { getByText } = render(); + const { getByTestId } = render(); - expect(getByText('0')).toBeTruthy(); - expect(getByText('points')).toBeTruthy(); + expect(getByTestId('error-retry-button')).toBeOnTheScreen(); }); - it('should call formatNumber with correct balance value', () => { - mockSelectBalanceTotal.mockReturnValue(1500); + it('calls fetchSeasonStatus when retry button is pressed', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return true; + if (selector === selectSeasonStartDate) return null; + return undefined; + }); + + const { getByTestId } = render(); - render(); + const retryButton = getByTestId('error-retry-button'); + fireEvent.press(retryButton); - expect(mockFormatNumber).toHaveBeenCalledWith(1500); + expect(mockFetchSeasonStatus).toHaveBeenCalledTimes(1); }); - }); - describe('Next Tier Points Display', () => { - it('should display next tier points needed when available', () => { - mockSelectNextTierPointsNeeded.mockReturnValue(500); - mockFormatNumber.mockReturnValue('500'); + it('does not render error banner when error exists but season start date is present', () => { + // When there's an error but we have cached data (seasonStartDate exists), + // component should render the normal state + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return true; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); - const { getByText } = render(); + const { getByTestId, queryByTestId } = render(); - expect(getByText('500 to level up')).toBeTruthy(); + // Normal state renders the points image instead of error banner + expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); + expect(queryByTestId('rewards-error-banner')).toBeNull(); }); - it('should not display next tier points when not available', () => { - mockSelectNextTierPointsNeeded.mockReturnValue(null); + it('renders normal content when there is no error', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); - const { queryByText } = render(); + const { getByTestId } = render(); - expect(queryByText('to level up')).toBeNull(); + expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); }); - it('should not display next tier points when zero', () => { - mockSelectNextTierPointsNeeded.mockReturnValue(0); + it('does not render points image when error state shows', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return true; + if (selector === selectSeasonStartDate) return null; + return undefined; + }); - const { queryByText } = render(); + const { queryByTestId } = render(); - expect(queryByText('to level up')).toBeNull(); + expect(queryByTestId('metamask-rewards-points-image')).toBeNull(); }); }); - describe('Component Lifecycle', () => { - it('should render without crashing', () => { - expect(() => render()).not.toThrow(); + describe('normal state - points display', () => { + it('renders points image', () => { + const { getByTestId } = render(); + + expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); }); - it('should cleanup properly when unmounted', () => { - const { unmount } = render(); + it('displays formatted balance total', () => { + mockFormatNumber.mockReturnValue('1,500'); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBalanceTotal) return 1500; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + return undefined; + }); - expect(() => unmount()).not.toThrow(); - }); + const { getByText } = render(); - it('should call useSeasonStatus with onlyForExplicitFetch: true', () => { - render(); + expect(getByText('1,500')).toBeOnTheScreen(); + }); - expect(mockUseSeasonStatus).toHaveBeenCalledWith({ - onlyForExplicitFetch: true, + it('calls formatNumber with balance total', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBalanceTotal) return 2500; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + return undefined; }); - }); - }); - describe('RewardsThemeImageComponent Integration', () => { - it('should render RewardsThemeImageComponent with correct testID when tier has image', () => { - const { getByTestId } = render(); + render(); - expect(getByTestId('season-tier-image')).toBeTruthy(); + expect(mockFormatNumber).toHaveBeenCalledWith(2500); }); - it('should pass correct themeImage prop to RewardsThemeImageComponent', () => { - const { getByTestId } = render(); + it('renders points section with balance total and season info', () => { + // The points section contains balance total and points label + // We verify the structure renders correctly + const { getByText } = render(); - const tierImage = getByTestId('season-tier-image'); - expect(tierImage.props['data-light-mode-url']).toBe('lightModeUrl'); - expect(tierImage.props['data-dark-mode-url']).toBe('darkModeUrl'); - }); + // Balance total is visible + expect(getByText('1,500')).toBeOnTheScreen(); - it('should pass correct style prop to RewardsThemeImageComponent', () => { - const { getByTestId } = render(); + // Season info is visible + expect(getByText('Season 1')).toBeOnTheScreen(); - const tierImage = getByTestId('season-tier-image'); - expect(tierImage.props.style).toBeDefined(); + // Time remaining is shown + expect(getByText('10d 5h 30m')).toBeOnTheScreen(); }); + }); - it('should render fallback Image when tier has no image', () => { - // Given: tier without image - mockSelectCurrentTier.mockReturnValue({ - id: 'bronze', - name: 'bronze', - pointsNeeded: 0, - image: undefined, - levelNumber: 'Level 1', - rewards: [], - } as unknown as SeasonTierDto); + describe('normal state - season info display', () => { + it('displays season name when provided', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonName) return 'Summer Season'; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); - const { queryByTestId } = render(); + const { getByText } = render(); - // RewardsThemeImageComponent should not be rendered - expect(queryByTestId('season-tier-image')).toBeNull(); + expect(getByText('Summer Season')).toBeOnTheScreen(); }); - it('should render RewardsThemeImageComponent with updated image when tier changes', () => { - // Given: initial tier with image - const { getByTestId, rerender } = render(); - const initialTierImage = getByTestId('season-tier-image'); - expect(initialTierImage.props['data-light-mode-url']).toBe( - 'lightModeUrl', - ); - - // When: tier changes to different image URLs - mockSelectCurrentTier.mockReturnValue({ - id: 'silver', - name: 'silver', - pointsNeeded: 2000, - image: { - lightModeUrl: 'newLightModeUrl', - darkModeUrl: 'newDarkModeUrl', - }, - levelNumber: 'Level 2', - rewards: [], + it('does not display season name when null', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonName) return null; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectBalanceTotal) return 1500; + return undefined; }); - rerender(); - // Then: new image URLs are used - const updatedTierImage = getByTestId('season-tier-image'); - expect(updatedTierImage.props['data-light-mode-url']).toBe( - 'newLightModeUrl', - ); - expect(updatedTierImage.props['data-dark-mode-url']).toBe( - 'newDarkModeUrl', - ); + const { queryByText } = render(); + + expect(queryByText('Summer Season')).toBeNull(); }); - }); - describe('Memoized Values', () => { - it('should update displayed points when balance changes', () => { - // Given: initial balance of 1500 - mockFormatNumber.mockReturnValue('1,500'); - const { getByText, rerender } = render(); - expect(getByText('1,500')).toBeTruthy(); - expect(getByText('points')).toBeTruthy(); - - // When: balance changes to 1000 - mockSelectBalanceTotal.mockReturnValue(1000); - mockFormatNumber.mockReturnValue('1,000'); - rerender(); - - // Then: new balance is displayed - expect(getByText('1,000')).toBeTruthy(); - expect(getByText('points')).toBeTruthy(); - }); - - it('should update time remaining when seasonEndDate changes', () => { - // Given: initial time showing "15d 10h" - const { getByText, rerender } = render(); - expect(getByText('15d 10h')).toBeTruthy(); - - // When: end date changes and formatTimeRemaining returns different value - mockSelectSeasonEndDate.mockReturnValue(new Date('2025-01-01T00:00:00Z')); - mockFormatTimeRemaining.mockReturnValue('30d 5h'); - rerender(); - - // Then: new time remaining is displayed - expect(getByText('30d 5h')).toBeTruthy(); - }); - - it('should update tier display when currentTier changes', () => { - // Given: initial tier showing "Level 1" and "Bronze" - const { getByText, rerender } = render(); - expect(getByText('Level 1')).toBeTruthy(); - expect(getByText('Bronze')).toBeTruthy(); - - // When: current tier changes to gold - mockSelectCurrentTier.mockReturnValue({ - id: 'gold', - name: 'gold', - pointsNeeded: 5000, - image: { - lightModeUrl: 'lightModeUrl', - darkModeUrl: 'darkModeUrl', - }, - levelNumber: 'Level 3', - rewards: [], + it('does not display season name text when value is empty string', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonName) return ''; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectBalanceTotal) return 1500; + return undefined; }); - rerender(); - // Then: new tier level and name are displayed - expect(getByText('Level 3')).toBeTruthy(); - expect(getByText('Gold')).toBeTruthy(); + const { queryByText } = render(); + + // Season 1 should not appear since it's set to empty string + expect(queryByText('Season 1')).toBeNull(); }); - }); - describe('seasonStatusError states', () => { - it('should not show error banner when no seasonStatusError', () => { - // Given: no error state - mockSelectSeasonStatusError.mockReturnValue(null); + it('displays time remaining when season end date is provided', () => { + mockFormatTimeRemaining.mockReturnValue('15d 8h 22m'); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonEndDate) return '2024-07-01'; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); - // When: component renders - const { queryByTestId, getByText } = render(); + const { getByText } = render(); - // Then: error banner should not be displayed, normal content should be shown - expect(queryByTestId('rewards-error-banner')).toBeNull(); - expect(getByText('Bronze')).toBeTruthy(); + expect(getByText('15d 8h 22m')).toBeOnTheScreen(); }); - it('should show normal content when seasonStatusError exists but seasonStartDate is available', () => { - // Given: error state but season start date is available - mockSelectSeasonStatusError.mockReturnValue('Network error'); - mockSelectSeasonStartDate.mockReturnValue( - new Date('2024-01-01T00:00:00Z'), - ); + it('does not display time remaining when season end date is null', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonEndDate) return null; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); - // When: component renders - const { getByText, queryByTestId } = render(); + const { queryByText } = render(); - // Then: normal content should be displayed, not error banner - expect(getByText('Bronze')).toBeTruthy(); - expect(queryByTestId('rewards-error-banner')).toBeNull(); + expect(queryByText(/d.*h.*m/)).toBeNull(); }); - it('should show error banner when seasonStatusError exists and no seasonStartDate', () => { - // Given: error state and no season start date - mockSelectSeasonStatusError.mockReturnValue('Network error'); - mockSelectSeasonStartDate.mockReturnValue(null); + it('does not display time remaining when formatTimeRemaining returns null', () => { + mockFormatTimeRemaining.mockReturnValue(null); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonEndDate) return '2024-01-01'; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); - // When: component renders - const { getByTestId, getByText } = render(); + const { queryByText } = render(); - // Then: error banner should be displayed - expect(getByTestId('rewards-error-banner')).toBeTruthy(); - expect(getByText("Season balance couldn't be loaded")).toBeTruthy(); - expect(getByText('Check your connection and try again.')).toBeTruthy(); - expect(getByText('Retry')).toBeTruthy(); + expect(queryByText(/d.*h.*m/)).toBeNull(); }); }); - describe('Image Modal Functionality', () => { - it('should render RewardsImageModal component', () => { - // Given: component with tier image - const { getByTestId } = render(); + describe('edge cases', () => { + it('renders with zero balance', () => { + mockFormatNumber.mockReturnValue('0'); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBalanceTotal) return 0; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + return undefined; + }); - // When: component is rendered - const modal = getByTestId('rewards-image-modal'); + const { getByText } = render(); - // Then: modal should be present - expect(modal).toBeTruthy(); + expect(getByText('0')).toBeOnTheScreen(); }); - it('should render modal with visible set to false initially', () => { - // Given: component is rendered - const { getByTestId } = render(); + it('renders with null balance', () => { + mockFormatNumber.mockReturnValue('0'); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBalanceTotal) return null; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + return undefined; + }); - // When: modal is checked - const modal = getByTestId('rewards-image-modal'); + const { getByText } = render(); - // Then: modal should not be visible - expect(modal.props['data-visible']).toBe(false); + expect(mockFormatNumber).toHaveBeenCalledWith(null); + expect(getByText('0')).toBeOnTheScreen(); }); - it('should pass correct themeImage prop to RewardsImageModal', () => { - // Given: component with current tier image - const { getByTestId } = render(); + it('renders with large balance', () => { + mockFormatNumber.mockReturnValue('1,000,000'); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectBalanceTotal) return 1000000; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + return undefined; + }); - // When: modal is checked - const modal = getByTestId('rewards-image-modal'); + const { getByText } = render(); - // Then: themeImage should match currentTier image - expect(modal.props['data-theme-image']).toEqual( - defaultMockValues.currentTier.image, - ); + expect(getByText('1,000,000')).toBeOnTheScreen(); }); - it('should pass fallback image to RewardsImageModal', () => { - // Given: component is rendered - const { getByTestId } = render(); + it('renders correctly when only loading state changes', () => { + // First render with loading + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return true; + return undefined; + }); - // When: modal is checked - const modal = getByTestId('rewards-image-modal'); + const { rerender, getByTestId, queryByTestId } = render( + , + ); - // Then: fallback image should be passed - expect(modal.props['data-fallback-image']).toBeDefined(); + expect(getByTestId('skeleton-loader')).toBeOnTheScreen(); + + // Then update to not loading + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); + mockFormatNumber.mockReturnValue('1,500'); + mockFormatTimeRemaining.mockReturnValue('10d 5h 30m'); + + rerender(); + + expect(queryByTestId('skeleton-loader')).toBeNull(); + expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); }); + }); - it('should wrap tier image in TouchableOpacity', () => { - // Given: component is rendered - const { UNSAFE_getByType } = render(); + describe('timeRemaining calculation', () => { + it('calls formatTimeRemaining with Date object from season end date string', () => { + const endDateString = '2024-12-31T23:59:59.000Z'; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonEndDate) return endDateString; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); - // When: looking for TouchableOpacity - const touchables = - UNSAFE_getByType as unknown as typeof UNSAFE_getByType & - ((type: unknown) => { props: { onPress: () => void } }); - const { TouchableOpacity } = jest.requireActual('react-native'); + render(); - // Then: TouchableOpacity should be present - expect(() => touchables(TouchableOpacity)).not.toThrow(); + expect(mockFormatTimeRemaining).toHaveBeenCalledWith( + new Date(endDateString), + ); }); - it('should render tier image with TouchableOpacity when tier has image', () => { - // Given: tier with image - const { getByTestId } = render(); + it('does not call formatTimeRemaining when season end date is null', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonEndDate) return null; + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 1500; + return undefined; + }); - // When: component is rendered - const tierImage = getByTestId('season-tier-image'); + render(); - // Then: tier image should be rendered - expect(tierImage).toBeTruthy(); - expect(tierImage.props['data-light-mode-url']).toBe('lightModeUrl'); + expect(mockFormatTimeRemaining).not.toHaveBeenCalled(); }); + }); - it('should update modal themeImage when tier changes', () => { - // Given: initial tier with image - const { getByTestId, rerender } = render(); - let modal = getByTestId('rewards-image-modal'); - expect(modal.props['data-theme-image']).toEqual( - defaultMockValues.currentTier.image, - ); - - // When: tier changes to different image - mockSelectCurrentTier.mockReturnValue({ - id: 'gold', - name: 'gold', - pointsNeeded: 5000, - image: { - lightModeUrl: 'newLightUrl', - darkModeUrl: 'newDarkUrl', - }, - levelNumber: 'Level 3', - rewards: [], + describe('component rendering without crashing', () => { + it('renders without crashing with minimal props', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return null; + if (selector === selectSeasonName) return null; + if (selector === selectBalanceTotal) return null; + return undefined; }); - rerender(); - // Then: modal should receive updated themeImage - modal = getByTestId('rewards-image-modal'); - expect(modal.props['data-theme-image']).toEqual({ - lightModeUrl: 'newLightUrl', - darkModeUrl: 'newDarkUrl', - }); + expect(() => render()).not.toThrow(); }); - it('should pass undefined themeImage to modal when tier has no image', () => { - // Given: tier without image - mockSelectCurrentTier.mockReturnValue({ - id: 'bronze', - name: 'bronze', - pointsNeeded: 0, - image: undefined, - levelNumber: 'Level 1', - rewards: [], - } as unknown as SeasonTierDto); - - // When: component is rendered - const { getByTestId } = render(); + it('renders without crashing with all values present', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectSeasonStatusLoading) return false; + if (selector === selectSeasonStatusError) return false; + if (selector === selectSeasonStartDate) return '2024-01-01'; + if (selector === selectSeasonEndDate) return '2024-12-31'; + if (selector === selectSeasonName) return 'Season 1'; + if (selector === selectBalanceTotal) return 5000; + return undefined; + }); - // Then: modal should receive undefined themeImage - const modal = getByTestId('rewards-image-modal'); - expect(modal.props['data-theme-image']).toBeUndefined(); + expect(() => render()).not.toThrow(); }); }); }); diff --git a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx index fa4bf67b43e..6a7b847a220 100644 --- a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx +++ b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx @@ -1,80 +1,43 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Box, BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, TextVariant, Text, FontWeight, - BoxAlignItems, } from '@metamask/design-system-react-native'; -import ProgressBar from 'react-native-progress/Bar'; import { strings } from '../../../../../../locales/i18n'; import { useTheme } from '../../../../../util/theme'; import MetamaskRewardsPointsImage from '../../../../../images/rewards/metamask-rewards-points.svg'; import { Skeleton } from '../../../../../component-library/components/Skeleton'; -import { capitalize } from 'lodash'; import { useSelector } from 'react-redux'; -import RewardsErrorBanner from '../RewardsErrorBanner'; import { selectSeasonStatusLoading, - selectSeasonTiers, selectBalanceTotal, selectSeasonEndDate, - selectNextTierPointsNeeded, - selectCurrentTier, - selectNextTier, + selectSeasonName, selectSeasonStatusError, selectSeasonStartDate, } from '../../../../../reducers/rewards/selectors'; import { formatNumber, formatTimeRemaining } from '../../utils/formatUtils'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import RewardsThemeImageComponent from '../ThemeImageComponent'; -import { Image, TouchableOpacity } from 'react-native'; -import fallbackTierImage from '../../../../../images/rewards/tiers/rewards-s1-tier-1.png'; +import RewardsErrorBanner from '../RewardsErrorBanner'; import { useSeasonStatus } from '../../hooks/useSeasonStatus'; -import RewardsImageModal from '../RewardsImageModal'; -import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; const SeasonStatus: React.FC = () => { + const theme = useTheme(); + const { fetchSeasonStatus } = useSeasonStatus({ + onlyForExplicitFetch: false, + }); const tw = useTailwind(); - const currentTier = useSelector(selectCurrentTier); - const nextTier = useSelector(selectNextTier); - const nextTierPointsNeeded = useSelector(selectNextTierPointsNeeded); - const tiers = useSelector(selectSeasonTiers); const balanceTotal = useSelector(selectBalanceTotal); const seasonStatusLoading = useSelector(selectSeasonStatusLoading); const seasonStatusError = useSelector(selectSeasonStatusError); const seasonStartDate = useSelector(selectSeasonStartDate); const seasonEndDate = useSelector(selectSeasonEndDate); - const theme = useTheme(); - - const { fetchSeasonStatus } = useSeasonStatus({ onlyForExplicitFetch: true }); - - const [isImageExpanded, setIsImageExpanded] = useState(false); - - const handleImagePress = () => { - setIsImageExpanded(true); - }; - - const handleCloseModal = () => { - setIsImageExpanded(false); - }; - - const progress = React.useMemo(() => { - if (!currentTier || !balanceTotal) { - return 0; - } - if (!nextTier?.pointsNeeded || balanceTotal >= nextTier.pointsNeeded) { - return 1; - } - - const currentTierBaseline = currentTier.pointsNeeded; - const nextTierRequirement = nextTier.pointsNeeded; - const tierRange = nextTierRequirement - currentTierBaseline; - const progressInTier = balanceTotal - currentTierBaseline; - - return Math.max(0, progressInTier / tierRange); - }, [currentTier, nextTier, balanceTotal]); + const seasonName = useSelector(selectSeasonName); const timeRemaining = React.useMemo(() => { if (!seasonEndDate) { @@ -83,196 +46,83 @@ const SeasonStatus: React.FC = () => { return formatTimeRemaining(new Date(seasonEndDate)); }, [seasonEndDate]); - const tierName = React.useMemo(() => { - if (!currentTier?.name) { - return ''; - } - return capitalize(currentTier.name); - }, [currentTier?.name]); - - const currentTierOrder = React.useMemo(() => { - if (!tiers?.length || !currentTier) { - return 0; - } - return tiers.findIndex((tier) => tier.id === currentTier.id) + 1; - }, [tiers, currentTier]); - - if ((seasonStatusLoading || !currentTier) && !seasonStatusError) { - return ( - - - - ); + if (seasonStatusLoading) { + return ; } if (seasonStatusError && !seasonStartDate) { return ( - - { - fetchSeasonStatus(); - }} - confirmButtonLabel={strings( - 'rewards.season_status_error.retry_button', - )} - /> - + { + fetchSeasonStatus(); + }} + confirmButtonLabel={strings('rewards.season_status_error.retry_button')} + /> ); } return ( - {/* Top Row - season name, tier name, and tier image */} - - - {/* Tier image - tappable to expand */} - - {currentTier?.image ? ( - - ) : ( - - )} - - - {/* Tier name */} - - - {strings('rewards.level')} {currentTierOrder} - - - {tierName} - - - - - {/* Season ends */} - {!!seasonEndDate && !!timeRemaining && ( - - - {strings('rewards.season_ends')} - - - {timeRemaining} - - - )} - - - {/* Middle Row - Progress bar */} - - {/* First progress bar - filled portion */} - {!!progress && ( - - - - )} - - {/* Second progress bar - unfilled portion */} - {progress < 1 && ( - - - - )} - - - {/* Bottom Row - Points Summary */} + {/* Left side - Points */} - - - - - - {formatNumber(balanceTotal)} - - - - {!balanceTotal || balanceTotal > 1 - ? strings('rewards.points').toLowerCase() - : strings('rewards.point').toLowerCase()} - - + + + + {formatNumber(balanceTotal)} + + + {strings('rewards.season_status.points_earned')} + + - {!!nextTierPointsNeeded && ( + {/* Right side - Season info */} + + {!!seasonName && ( - {formatNumber(nextTierPointsNeeded)}{' '} - {strings('rewards.to_level_up').toLowerCase()} + {seasonName} + + )} + {!!timeRemaining && ( + + {timeRemaining} )} - - {/* Full-screen image modal */} - ); }; diff --git a/locales/languages/en.json b/locales/languages/en.json index 503acec3530..3091fa009e7 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7167,10 +7167,7 @@ "retry_button": "Retry" }, "referral_rewards_title": "Referrals", - "points": "Points", - "point": "Point", "level": "Level", - "to_level_up": "To level up", "season_ends": "Season ends", "season_ended": "Season ended", "main_title": "Rewards", @@ -7229,6 +7226,9 @@ "no_end_of_season_rewards": "You didn't earn rewards this season, but there's always next time.", "verifying_rewards": "We're making sure everything's correct before you claim your rewards." }, + "season_status": { + "points_earned": "Points earned" + }, "onboarding": { "not_supported_region_title": "Region not supported", "not_supported_region_description": "Rewards are not supported in your region yet. We are working on expanding access, so check back later.", From 4e705b62a6b79616cbae66bf297555abd567731d Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:14:55 +0800 Subject: [PATCH 03/18] feat(perps): improve load time with non-blocking price prewarm (#25501) ## **Description** Makes `prices.prewarm()` non-blocking to improve Perps home screen load time. **Problem:** The `prices.prewarm()` method was blocking initialization by awaiting `getMarkets()` before returning, causing users to wait for all 269 market symbols to load before seeing any UI. **Solution:** Convert to fire-and-forget pattern - return the cleanup function immediately while market fetching and subscription happen in the background. ## **Changelog** CHANGELOG entry: Improved Perps home screen load time by making price prewarming non-blocking ## **Related issues** Fixes: N/A (Performance optimization) ## **Manual testing steps** ```gherkin Feature: Perps load time optimization Scenario: User opens Perps home screen Given the user is on the main wallet screen When user navigates to Perps home screen Then the screen should load faster (UI becomes interactive sooner) And prices should appear after a brief delay as background fetch completes And no errors should appear in console Scenario: User leaves Perps before prices load Given the user just opened Perps home screen When user navigates away before prices appear Then no errors should occur And no orphaned subscriptions should remain ``` ## **Screenshots/Recordings** ### **Before** N/A - Performance improvement, no visual changes ### **After** N/A - Performance improvement, no visual changes ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes the lifecycle of the Perps price WebSocket subscription to be asynchronous, which could affect when/if price updates start and how cleanup behaves under rapid navigation. Added tests reduce risk but regressions could still impact pricing display and connection stability. > > **Overview** > Improves Perps home load time by making `PriceStreamChannel.prewarm()` return a cleanup function immediately, while `getMarkets()` and the all-markets `subscribeToPrices` setup run in the background. > > Adds leak/race protections for rapid enter/exit flows (cycle ID to ignore stale `getMarkets()` resolutions, separate tracking of the *actual* unsubscribe), and hardens error handling to log background fetch failures and recover by resetting state and reconnecting active subscribers. Updates tests to cover the new non-blocking behavior, cleanup-before-fetch, stale-promise suppression, and error paths. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit df2a28bc7b30705964b711cc29d621342f9f9c83. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Opus 4.5 --- .../providers/PerpsStreamManager.test.tsx | 238 ++++++++++++++++++ .../UI/Perps/providers/PerpsStreamManager.tsx | 134 +++++++--- 2 files changed, 331 insertions(+), 41 deletions(-) diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 435c084d28c..5d6bdd70783 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -84,6 +84,9 @@ describe('PerpsStreamManager', () => { subscribeToPositions: mockSubscribeToPositions, subscribeToAccount: mockSubscribeToAccount, isCurrentlyReinitializing: jest.fn().mockReturnValue(false), + getMarkets: jest + .fn() + .mockResolvedValue([{ name: 'BTC-PERP' }, { name: 'ETH-PERP' }]), } as unknown as typeof mockEngine.context.PerpsController; // Mock AccountTreeController for getEvmAccountFromSelectedAccountGroup @@ -940,6 +943,241 @@ describe('PerpsStreamManager', () => { }); }); + describe('PriceStreamChannel.prewarm non-blocking behavior', () => { + it('returns immediately without waiting for getMarkets', async () => { + // Create a promise that we can control + let resolveGetMarkets: (value: { name: string }[]) => void = jest.fn(); + const getMarketsPromise = new Promise<{ name: string }[]>((resolve) => { + resolveGetMarkets = resolve; + }); + + mockEngine.context.PerpsController.getMarkets = jest + .fn() + .mockReturnValue(getMarketsPromise); + + // prewarm should return immediately + const cleanupPromise = testStreamManager.prices.prewarm(); + + // The promise should resolve immediately (before getMarkets completes) + const cleanup = await cleanupPromise; + expect(typeof cleanup).toBe('function'); + + // getMarkets was called but not awaited + expect(mockEngine.context.PerpsController.getMarkets).toHaveBeenCalled(); + + // subscribeToPrices should NOT have been called yet + expect(mockSubscribeToPrices).not.toHaveBeenCalled(); + + // Now resolve getMarkets + resolveGetMarkets([{ name: 'BTC-PERP' }, { name: 'ETH-PERP' }]); + + // Wait for the promise chain to complete + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // Now subscribeToPrices should have been called + expect(mockSubscribeToPrices).toHaveBeenCalledWith({ + symbols: ['BTC-PERP', 'ETH-PERP'], + callback: expect.any(Function), + }); + + cleanup(); + }); + + it('skips subscription when cleanup occurs before getMarkets completes', async () => { + // Create a promise that we can control + let resolveGetMarkets: (value: { name: string }[]) => void = jest.fn(); + const getMarketsPromise = new Promise<{ name: string }[]>((resolve) => { + resolveGetMarkets = resolve; + }); + + mockEngine.context.PerpsController.getMarkets = jest + .fn() + .mockReturnValue(getMarketsPromise); + + // prewarm should return immediately + const cleanup = await testStreamManager.prices.prewarm(); + + // Call cleanup before getMarkets resolves + cleanup(); + + // Now resolve getMarkets + resolveGetMarkets([{ name: 'BTC-PERP' }, { name: 'ETH-PERP' }]); + + // Wait for the promise chain to complete + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // subscribeToPrices should NOT have been called (cleaned up before it could subscribe) + expect(mockSubscribeToPrices).not.toHaveBeenCalled(); + }); + + it('logs error and returns cleanup function when getMarkets fails', async () => { + mockEngine.context.PerpsController.getMarkets = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + // prewarm should return immediately + const cleanup = await testStreamManager.prices.prewarm(); + expect(typeof cleanup).toBe('function'); + + // Wait for the promise chain to complete + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // subscribeToPrices should NOT have been called due to error + expect(mockSubscribeToPrices).not.toHaveBeenCalled(); + + // Logger.error should have been called + expect(mockLogger.error).toHaveBeenCalled(); + + cleanup(); + }); + + it('calls actual unsubscribe when cleanupPrewarm runs after subscription is established', async () => { + const mockActualUnsubscribe = jest.fn(); + mockSubscribeToPrices.mockReturnValue(mockActualUnsubscribe); + + mockEngine.context.PerpsController.getMarkets = jest + .fn() + .mockResolvedValue([{ name: 'BTC-PERP' }]); + + // prewarm and wait for subscription to be established + const cleanup = await testStreamManager.prices.prewarm(); + + // Wait for the background subscription to be set up + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockSubscribeToPrices).toHaveBeenCalled(); + + // Call cleanup + cleanup(); + + // The actual unsubscribe should have been called + expect(mockActualUnsubscribe).toHaveBeenCalled(); + }); + + it('returns same cleanup when prewarm called twice without cleanup (already pre-warmed guard)', async () => { + const mockUnsubscribe = jest.fn(); + mockSubscribeToPrices.mockReturnValue(mockUnsubscribe); + + // Create controlled promise + let resolveGetMarkets: (value: { name: string }[]) => void = jest.fn(); + const getMarketsPromise = new Promise<{ name: string }[]>((resolve) => { + resolveGetMarkets = resolve; + }); + + mockEngine.context.PerpsController.getMarkets = jest + .fn() + .mockReturnValue(getMarketsPromise); + + // Cycle 1: User enters Perps + const cleanup1 = await testStreamManager.prices.prewarm(); + + // Cycle 2: Called again without cleanup (rapid navigation) + // The guard at line 362 returns the existing prewarmUnsubscribe + const cleanup2 = await testStreamManager.prices.prewarm(); + + // Both cleanups should be the same function (guarded by "already pre-warmed" check) + expect(cleanup1).toBe(cleanup2); + + // getMarkets only called once (second call was short-circuited) + expect( + mockEngine.context.PerpsController.getMarkets, + ).toHaveBeenCalledTimes(1); + + // Resolve and cleanup + resolveGetMarkets([{ name: 'BTC-PERP' }]); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockSubscribeToPrices).toHaveBeenCalledTimes(1); + cleanup1(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('prevents stale promise from creating subscription after cleanup and new prewarm', async () => { + const mockUnsubscribe1 = jest.fn(); + const mockUnsubscribe2 = jest.fn(); + let subscribeCallCount = 0; + + mockSubscribeToPrices.mockImplementation(() => { + subscribeCallCount++; + return subscribeCallCount === 1 ? mockUnsubscribe1 : mockUnsubscribe2; + }); + + // Create controlled promises for each cycle + let resolveGetMarkets1: (value: { name: string }[]) => void = jest.fn(); + let resolveGetMarkets2: (value: { name: string }[]) => void = jest.fn(); + + const getMarketsPromise1 = new Promise<{ name: string }[]>((resolve) => { + resolveGetMarkets1 = resolve; + }); + const getMarketsPromise2 = new Promise<{ name: string }[]>((resolve) => { + resolveGetMarkets2 = resolve; + }); + + let getMarketsCallCount = 0; + (mockEngine.context.PerpsController.getMarkets as jest.Mock) = jest.fn( + () => { + getMarketsCallCount++; + return getMarketsCallCount === 1 + ? getMarketsPromise1 + : getMarketsPromise2; + }, + ); + + // Cycle 1: User enters Perps + const cleanup1 = await testStreamManager.prices.prewarm(); + + // User leaves before markets load - this resets prewarmUnsubscribe to undefined + cleanup1(); + + // Cycle 2: User enters Perps again + const cleanup2 = await testStreamManager.prices.prewarm(); + + // Now cycle 1's promise resolves (STALE - should be ignored due to cycle ID mismatch) + resolveGetMarkets1([{ name: 'BTC-PERP' }]); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // Stale promise should NOT create subscription (cycle ID mismatch) + expect(mockSubscribeToPrices).not.toHaveBeenCalled(); + + // Cycle 2's promise resolves (active) + resolveGetMarkets2([{ name: 'ETH-PERP' }]); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // Only one subscription should be created (from cycle 2) + expect(mockSubscribeToPrices).toHaveBeenCalledTimes(1); + expect(mockSubscribeToPrices).toHaveBeenCalledWith({ + symbols: ['ETH-PERP'], + callback: expect.any(Function), + }); + + // Cleanup cycle 2 - since this is the first subscription call, it uses mockUnsubscribe1 + cleanup2(); + expect(mockUnsubscribe1).toHaveBeenCalled(); + // mockUnsubscribe2 was never created because only one subscription was made + }); + }); + it('should throttle subsequent updates', async () => { const onUpdate = jest.fn(); let priceCallback: (data: PriceUpdate[]) => void = jest.fn(); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 0cc19de3876..eaec6098be5 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -236,7 +236,10 @@ abstract class StreamChannel { class PriceStreamChannel extends StreamChannel> { private symbols = new Set(); private prewarmUnsubscribe?: () => void; + private actualPriceUnsubscribe?: () => void; private allMarketSymbols: string[] = []; + // Unique ID per prewarm cycle to detect stale promises and prevent subscription leaks + private prewarmCycleId: number = 0; // Override cache to store individual PriceUpdate objects protected priceCache = new Map(); @@ -355,6 +358,7 @@ class PriceStreamChannel extends StreamChannel> { /** * Pre-warm the channel by subscribing to all market prices * This keeps a single WebSocket connection alive with all price updates + * Non-blocking: Returns immediately while market fetch happens in background * @returns Cleanup function to call when leaving Perps environment */ public async prewarm(): Promise<() => void> { @@ -364,57 +368,104 @@ class PriceStreamChannel extends StreamChannel> { } try { - // Get all available market symbols const controller = Engine.context.PerpsController; - const markets = await controller.getMarkets(); - this.allMarketSymbols = markets.map((market) => market.name); - DevLogger.log('PriceStreamChannel: Pre-warming with all market symbols', { - symbolCount: this.allMarketSymbols.length, - symbols: this.allMarketSymbols.slice(0, 10), // Log first 10 for debugging - }); + // Increment cycle ID to detect stale promises from previous prewarm cycles + // This prevents subscription leaks when user navigates: Perps → away → back quickly + this.prewarmCycleId++; + const currentCycleId = this.prewarmCycleId; + + // Start market fetch in background (non-blocking) + // We need the symbols to register subscribers, but we can return immediately + const marketsPromise = controller.getMarkets(); + + // Set up subscription once markets arrive (fire-and-forget) + marketsPromise + .then((markets) => { + // If this promise is from a stale cycle, don't set up subscription + // This prevents leaks when prewarm is called multiple times rapidly + if (currentCycleId !== this.prewarmCycleId) { + DevLogger.log('PriceStreamChannel: Skipping stale prewarm cycle', { + currentCycleId, + activeCycleId: this.prewarmCycleId, + }); + return; + } + + // If already cleaned up, don't set up subscription + if (this.prewarmUnsubscribe === undefined) { + return; + } + + this.allMarketSymbols = markets.map((market) => market.name); + + DevLogger.log( + 'PriceStreamChannel: Pre-warming with all market symbols', + { + symbolCount: this.allMarketSymbols.length, + symbols: this.allMarketSymbols.slice(0, 10), + }, + ); - // Subscribe to all market prices - this.prewarmUnsubscribe = controller.subscribeToPrices({ - symbols: this.allMarketSymbols, - callback: (updates: PriceUpdate[]) => { - // Update cache and build price map - const priceMap: Record = {}; - updates.forEach((update) => { - const priceUpdate: PriceUpdate = { - symbol: update.symbol, - price: update.price, - timestamp: Date.now(), - percentChange24h: update.percentChange24h, - bestBid: update.bestBid, - bestAsk: update.bestAsk, - spread: update.spread, - markPrice: update.markPrice, - funding: update.funding, - openInterest: update.openInterest, - volume24h: update.volume24h, - }; - this.priceCache.set(update.symbol, priceUpdate); - priceMap[update.symbol] = priceUpdate; + // Subscribe to all market prices + const unsub = controller.subscribeToPrices({ + symbols: this.allMarketSymbols, + callback: (updates: PriceUpdate[]) => { + const priceMap: Record = {}; + updates.forEach((update) => { + const priceUpdate: PriceUpdate = { + symbol: update.symbol, + price: update.price, + timestamp: Date.now(), + percentChange24h: update.percentChange24h, + bestBid: update.bestBid, + bestAsk: update.bestAsk, + spread: update.spread, + markPrice: update.markPrice, + funding: update.funding, + openInterest: update.openInterest, + volume24h: update.volume24h, + }; + this.priceCache.set(update.symbol, priceUpdate); + priceMap[update.symbol] = priceUpdate; + }); + + if (this.subscribers.size > 0) { + this.notifySubscribers(priceMap); + } + }, }); - // Notify any active subscribers with all updates + // Store the actual unsubscribe function + this.actualPriceUnsubscribe = unsub; + }) + .catch((error) => { + Logger.error( + ensureError(error, 'PriceStreamChannel.prewarm.backgroundFetch'), + { + context: 'PriceStreamChannel.prewarm.backgroundFetch', + }, + ); + // Reset state so subsequent prewarm/connect calls can recover + this.prewarmUnsubscribe = undefined; + this.allMarketSymbols = []; + // Reconnect waiting subscribers that were skipped because prewarm was pending if (this.subscribers.size > 0) { - this.notifySubscribers(priceMap); + this.connect(); } - }, - }); + }); - // Return a cleanup function that properly clears internal state - return () => { + // Return cleanup function immediately (before markets load) + this.prewarmUnsubscribe = () => { DevLogger.log('PriceStreamChannel: Cleaning up prewarm subscription'); this.cleanupPrewarm(); }; + + return this.prewarmUnsubscribe; } catch (error) { Logger.error(ensureError(error, 'PriceStreamChannel.prewarm'), { context: 'PriceStreamChannel.prewarm', }); - // Return no-op cleanup function return () => { // No-op }; @@ -425,11 +476,12 @@ class PriceStreamChannel extends StreamChannel> { * Cleanup pre-warm subscription */ public cleanupPrewarm(): void { - if (this.prewarmUnsubscribe) { - this.prewarmUnsubscribe(); - this.prewarmUnsubscribe = undefined; - this.allMarketSymbols = []; + if (this.actualPriceUnsubscribe) { + this.actualPriceUnsubscribe(); + this.actualPriceUnsubscribe = undefined; } + this.prewarmUnsubscribe = undefined; + this.allMarketSymbols = []; } } @@ -1285,7 +1337,7 @@ class MarketDataChannel extends StreamChannel { public prewarm(): () => void { // Fetch data immediately to populate cache this.fetchMarketData().catch((error) => { - Logger.error(error instanceof Error ? error : new Error(String(error)), { + Logger.error(ensureError(error, 'MarketDataChannel.prewarm'), { context: 'MarketDataChannel.prewarm', }); }); From 33436d57d1f54f45c2627054999152893d2130c8 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:21:34 -0500 Subject: [PATCH 04/18] feat: support rewards opt-in for all accounts (#24450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** https://consensyssoftware.atlassian.net/jira/software/c/projects/RWDS/boards/3533?selectedIssue=RWDS-582 Supports bulk link all accounts to a Rewards subscription through a redux saga. **Features:** - Navigation-independent: runs at the saga level, so it continues across navigation. - Real-time progress: processes accounts sequentially and updates Redux after each account. - Batch optimization: fetches opt-in status for all addresses in one call before processing. **Performance tuning:** - Cache invalidation every 100 accounts (and on the last account) - Yields to UI thread every 2 accounts to prevent freezing - Early abort after 5 consecutive failures - Error handling: per-account errors don't stop the process; failures are tracked individually. - Cancellation support: can be cancelled via BULK_LINK_CANCEL action. **Architecture:** - bulkLinkWorker: main orchestration saga - watchBulkLink: watcher that handles start/cancel actions - Helper functions: account collection, batch status fetching, individual account linking - Action creators: startBulkLink() and cancelBulkLink() exported for backwards compatibility **Flow:** - Collect all supported accounts from all account groups - Batch fetch opt-in status for all addresses - Filter to accounts that need linking - Process accounts one-by-one with progress updates - Complete with success/failure statistics This enables bulk linking with progress tracking and UI responsiveness. ## **Changelog** CHANGELOG entry: Allow user to opt-in all accounts at once to Rewards ## **Related issues** Fixes: null ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Simulator Screenshot - E2E Test -
2026-01-09 at 13 27 24 Simulator Screenshot - E2E Test -
2026-01-08 at 21 30 31 Simulator Screenshot - E2E Test -
2026-01-08 at 21 06 18 Simulator Screenshot - E2E Test -
2026-01-08 at 20 58 04 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes Rewards opt-in/opt-out and settings behaviors by introducing a new bulk-link saga integration, auto-resume on focus, and UI gating while bulk linking; issues could impact account linking state and user onboarding flows. > > **Overview** > Adds a new `useBulkLinkState` hook that exposes bulk-link lifecycle actions (`start/cancel/resume/reset`) plus progress/summary fields sourced from Redux selectors, with comprehensive unit tests. > > Extends Rewards onboarding to optionally *opt-in all accounts*: `OnboardingStep4` and `OnboardingNoActiveSeasonStep` add a checkbox and pass `bulkLink` into `useOptin`, which now cancels any running bulk link before opting in, tracks a `bulk_link` metrics property, and starts bulk linking on successful opt-in instead of linking a single account group. > > Improves Rewards settings UX during bulk linking: `RewardSettingsAccountGroupList` adds a progress section with a progress bar and an “Add all accounts” button (wired to `startBulkLink`), plus per-wallet expand/collapse (“Show more/less”). Individual account-group link buttons are disabled while bulk linking is running, and `RewardSettingsAccountGroup` shows a loading indicator when linking. > > Adds resilience/guardrails: `RewardsDashboard` auto-resumes an interrupted bulk link on screen focus, `useRewardDashboardModals` suppresses the unlinked-accounts modal while bulk linking is running, `useOptout` cancels bulk linking before opt-out, and `Authentication.resetWalletState` dispatches `cancelBulkLink()` before resetting Rewards state. Tests are updated/expanded accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c07b6a3f9e5e5e38b733ac2aee08b58aff86a696. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Rik Van Gulck --- .../Rewards/Views/RewardsDashboard.test.tsx | 114 +- .../UI/Rewards/Views/RewardsDashboard.tsx | 15 + .../OnboardingNoActiveSeasonStep.tsx | 72 +- .../components/Onboarding/OnboardingStep.tsx | 4 +- .../components/Onboarding/OnboardingStep4.tsx | 180 ++-- .../OnboardingNoActiveSeasonStep.test.tsx | 152 ++- .../__tests__/OnboardingStep.test.tsx | 1 + .../__tests__/OnboardingStep4.test.tsx | 258 ++++- .../RewardSettingsAccountGroup.test.tsx | 108 +- .../Settings/RewardSettingsAccountGroup.tsx | 6 +- .../RewardSettingsAccountGroupList.test.tsx | 575 ++++++++++- .../RewardSettingsAccountGroupList.tsx | 274 ++++- .../UI/Rewards/components/Settings/types.ts | 5 +- .../UI/Rewards/hooks/useBulkLinkState.test.ts | 795 ++++++++++++++ .../UI/Rewards/hooks/useBulkLinkState.ts | 172 ++++ .../UI/Rewards/hooks/useOptIn.test.ts | 173 ++++ app/components/UI/Rewards/hooks/useOptIn.ts | 26 +- .../UI/Rewards/hooks/useOptOut.test.ts | 103 ++ app/components/UI/Rewards/hooks/useOptout.ts | 6 + .../hooks/useRewardDashboardModals.test.tsx | 80 ++ .../hooks/useRewardDashboardModals.tsx | 12 +- .../Authentication/Authentication.test.ts | 95 +- app/core/Authentication/Authentication.ts | 4 + .../RewardsController.test.ts | 596 +++++++---- .../rewards-controller/RewardsController.ts | 71 +- .../controllers/rewards-controller/types.ts | 5 +- app/reducers/rewards/index.test.ts | 970 +++++++++++++++++- app/reducers/rewards/index.ts | 209 +++- app/reducers/rewards/selectors.test.ts | 601 +++++++++++ app/reducers/rewards/selectors.ts | 32 + app/store/sagas/index.ts | 2 + .../rewardsBulkLinkAccountGroups.test.ts | 915 +++++++++++++++++ .../sagas/rewardsBulkLinkAccountGroups.ts | 492 +++++++++ locales/languages/en.json | 8 +- 34 files changed, 6618 insertions(+), 513 deletions(-) create mode 100644 app/components/UI/Rewards/hooks/useBulkLinkState.test.ts create mode 100644 app/components/UI/Rewards/hooks/useBulkLinkState.ts create mode 100644 app/store/sagas/rewardsBulkLinkAccountGroups.test.ts create mode 100644 app/store/sagas/rewardsBulkLinkAccountGroups.ts diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 3a4f45d6965..17676db1712 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -359,6 +359,10 @@ jest.mock('../hooks/useRewardDashboardModals', () => ({ useRewardDashboardModals: jest.fn(), })); +jest.mock('../hooks/useBulkLinkState', () => ({ + useBulkLinkState: jest.fn(), +})); + jest.mock('../utils', () => ({ convertInternalAccountToCaipAccountId: jest.fn(), })); @@ -544,6 +548,7 @@ jest.spyOn(Alert, 'alert').mockImplementation(mockAlert); import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useLinkAccountGroup } from '../hooks/useLinkAccountGroup'; import { useRewardDashboardModals } from '../hooks/useRewardDashboardModals'; +import { useBulkLinkState } from '../hooks/useBulkLinkState'; import { convertInternalAccountToCaipAccountId } from '../utils'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { AccountGroupType, AccountWalletType } from '@metamask/account-api'; @@ -558,6 +563,9 @@ const mockUseRewardDashboardModals = useRewardDashboardModals as jest.MockedFunction< typeof useRewardDashboardModals >; +const mockUseBulkLinkState = useBulkLinkState as jest.MockedFunction< + typeof useBulkLinkState +>; const mockConvertInternalAccountToCaipAccountId = convertInternalAccountToCaipAccountId as jest.MockedFunction< typeof convertInternalAccountToCaipAccountId @@ -571,6 +579,10 @@ describe('RewardsDashboard', () => { const mockShowNotSupportedModal = jest.fn(); const mockHasShownModal = jest.fn(); const mockResetSessionTracking = jest.fn(); + const mockResumeBulkLink = jest.fn(); + const mockStartBulkLink = jest.fn(); + const mockCancelBulkLink = jest.fn(); + const mockResetBulkLink = jest.fn(); const mockSelectedAccount = { id: 'account-1', @@ -637,6 +649,22 @@ describe('RewardsDashboard', () => { resetSessionTrackingForCurrentAccountGroup: jest.fn(), resetAllSessionTracking: jest.fn(), }, + useBulkLinkState: { + startBulkLink: mockStartBulkLink, + cancelBulkLink: mockCancelBulkLink, + resetBulkLink: mockResetBulkLink, + resumeBulkLink: mockResumeBulkLink, + isRunning: false, + wasInterrupted: false, + isCompleted: false, + hasFailures: false, + isFullySuccessful: false, + totalAccounts: 0, + linkedAccounts: 0, + failedAccounts: 0, + accountProgress: 0, + processedAccounts: 0, + }, }; beforeEach(() => { @@ -648,6 +676,10 @@ describe('RewardsDashboard', () => { mockShowNotSupportedModal.mockClear(); mockHasShownModal.mockClear(); mockResetSessionTracking.mockClear(); + mockResumeBulkLink.mockClear(); + mockStartBulkLink.mockClear(); + mockCancelBulkLink.mockClear(); + mockResetBulkLink.mockClear(); mockTrackEvent.mockClear(); mockCreateEventBuilder.mockClear(); mockBuild.mockClear(); @@ -693,6 +725,7 @@ describe('RewardsDashboard', () => { mockUseRewardDashboardModals.mockReturnValue( defaultHookValues.useRewardDashboardModals, ); + mockUseBulkLinkState.mockReturnValue(defaultHookValues.useBulkLinkState); mockConvertInternalAccountToCaipAccountId.mockReturnValue('eip155:1:0x123'); // Setup default modal hook behavior - return false for all modal types by default @@ -2255,12 +2288,12 @@ describe('RewardsDashboard', () => { }); describe('component lifecycle', () => { - it('should render without crashing', () => { + it('renders without crashing', () => { // Act & Assert expect(() => render()).not.toThrow(); }); - it('should cleanup properly when unmounted', () => { + it('cleans up properly when unmounted', () => { // Act const { unmount } = render(); @@ -2268,4 +2301,81 @@ describe('RewardsDashboard', () => { expect(() => unmount()).not.toThrow(); }); }); + + describe('bulk link auto-resume', () => { + it('calls resumeBulkLink when wasInterrupted is true and isRunning is false', () => { + // Arrange + mockUseBulkLinkState.mockReturnValue({ + ...defaultHookValues.useBulkLinkState, + wasInterrupted: true, + isRunning: false, + }); + + // Act + render(); + + // Assert + expect(mockResumeBulkLink).toHaveBeenCalled(); + }); + + it('does not call resumeBulkLink when wasInterrupted is false', () => { + // Arrange + mockUseBulkLinkState.mockReturnValue({ + ...defaultHookValues.useBulkLinkState, + wasInterrupted: false, + isRunning: false, + }); + + // Act + render(); + + // Assert + expect(mockResumeBulkLink).not.toHaveBeenCalled(); + }); + + it('does not call resumeBulkLink when isRunning is true', () => { + // Arrange + mockUseBulkLinkState.mockReturnValue({ + ...defaultHookValues.useBulkLinkState, + wasInterrupted: true, + isRunning: true, + }); + + // Act + render(); + + // Assert + expect(mockResumeBulkLink).not.toHaveBeenCalled(); + }); + + it('does not call resumeBulkLink when both wasInterrupted and isRunning are false', () => { + // Arrange + mockUseBulkLinkState.mockReturnValue({ + ...defaultHookValues.useBulkLinkState, + wasInterrupted: false, + isRunning: false, + }); + + // Act + render(); + + // Assert + expect(mockResumeBulkLink).not.toHaveBeenCalled(); + }); + + it('does not call resumeBulkLink when both wasInterrupted and isRunning are true', () => { + // Arrange + mockUseBulkLinkState.mockReturnValue({ + ...defaultHookValues.useBulkLinkState, + wasInterrupted: true, + isRunning: true, + }); + + // Act + render(); + + // Assert + expect(mockResumeBulkLink).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index e421f2c1e1a..cd41ee2c1d7 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -39,6 +39,7 @@ import { useRewardDashboardModals, RewardsDashboardModalType, } from '../hooks/useRewardDashboardModals'; +import { useBulkLinkState } from '../hooks/useBulkLinkState'; import RewardsOverview from '../components/Tabs/RewardsOverview'; import RewardsLevels from '../components/Tabs/RewardsLevels'; import RewardsActivity from '../components/Tabs/RewardsActivity'; @@ -100,6 +101,9 @@ const RewardsDashboard: React.FC = () => { currentAccountGroupOptedInStatus, } = useRewardOptinSummary(); + // Use the bulk link state hook for resuming interrupted opt-in processes + const { wasInterrupted, isRunning, resumeBulkLink } = useBulkLinkState(); + const totalOptedInAccountsSelectedGroup = useMemo( () => optInBySelectedAccountGroup?.optedInAccounts?.length, [optInBySelectedAccountGroup], @@ -194,6 +198,17 @@ const RewardsDashboard: React.FC = () => { boolean | null >(null); + // Auto-resume interrupted bulk link process when screen comes into focus. + // This handles the case where the app was closed during a bulk opt-in process. + // The saga is idempotent - it re-fetches opt-in status to skip already-linked accounts. + useFocusEffect( + useCallback(() => { + if (wasInterrupted && !isRunning) { + resumeBulkLink(); + } + }, [wasInterrupted, isRunning, resumeBulkLink]), + ); + // Evaluate showPreviousSeasonSummary when screen comes into focus useFocusEffect( useCallback(() => { diff --git a/app/components/UI/Rewards/components/Onboarding/OnboardingNoActiveSeasonStep.tsx b/app/components/UI/Rewards/components/Onboarding/OnboardingNoActiveSeasonStep.tsx index b8f13910c0c..cf7314b30bc 100644 --- a/app/components/UI/Rewards/components/Onboarding/OnboardingNoActiveSeasonStep.tsx +++ b/app/components/UI/Rewards/components/Onboarding/OnboardingNoActiveSeasonStep.tsx @@ -1,9 +1,10 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { Image, useWindowDimensions } from 'react-native'; import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useOptin } from '../../hooks/useOptIn'; import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import Checkbox from '../../../../../component-library/components/Checkbox'; import step1Img from '../../../../../images/rewards/rewards-onboarding-step1.png'; import Step1BgImg from '../../../../../images/rewards/rewards-onboarding-step1-bg.svg'; import { strings } from '../../../../../../locales/i18n'; @@ -35,39 +36,62 @@ const OnboardingNoActiveSeasonStep: React.FC< const navigation = useNavigation(); const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const { optin, optinError, optinLoading } = useOptin(); + const [bulkLink, setBulkLink] = useState(false); + + const handleBulkLinkToggle = useCallback(() => { + setBulkLink((prev) => !prev); + }, []); const handleNext = useCallback(() => { if (!canContinue()) { return; } - optin({}); - }, [optin, canContinue]); + optin({ bulkLink }); + }, [optin, canContinue, bulkLink]); const renderStepInfo = () => ( - - {/* Opt in error message */} - {optinError && ( - - )} + <> + + {/* Opt in error message */} + {optinError && ( + + )} - {/* Title and Description */} - - - {strings('rewards.onboarding.no_active_season.title')} - - - - {strings('rewards.onboarding.no_active_season.description')} - + {/* Title and Description */} + + + {strings('rewards.onboarding.no_active_season.title')} + + + + {strings('rewards.onboarding.no_active_season.description')} + + + + {/* Opt-in all accounts checkbox */} + + + {strings('rewards.onboarding.step4_bulk_link_checkbox')} + + } + /> - + ); const renderLegalDisclaimer = () => ( diff --git a/app/components/UI/Rewards/components/Onboarding/OnboardingStep.tsx b/app/components/UI/Rewards/components/Onboarding/OnboardingStep.tsx index da36b3870f4..5bb15817359 100644 --- a/app/components/UI/Rewards/components/Onboarding/OnboardingStep.tsx +++ b/app/components/UI/Rewards/components/Onboarding/OnboardingStep.tsx @@ -140,7 +140,9 @@ const OnboardingStepComponent: React.FC = ({ - {renderStepInfo()} + + {renderStepInfo()} +