diff --git a/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.test.tsx b/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.test.tsx index e9f10fbd947..b27f4fee5ab 100644 --- a/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.test.tsx +++ b/app/component-library/components-temp/Tabs/TabsIconBar/TabsIconBar.test.tsx @@ -21,7 +21,7 @@ describe('TabsIconBar', () => { { key: 'tab1', label: 'Portfolio', - iconName: IconName.Portfolio, + iconName: IconName.PieChart, content: null, }, { @@ -118,7 +118,7 @@ describe('TabsIconBar', () => { { key: 'tab1', label: 'Portfolio', - iconName: IconName.Portfolio, + iconName: IconName.PieChart, content: null, }, { @@ -393,7 +393,7 @@ describe('TabsIconBar', () => { { key: 'new1', label: 'New A', - iconName: IconName.Portfolio, + iconName: IconName.PieChart, content: null, }, { diff --git a/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.test.tsx b/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.test.tsx index 4b297781144..64b781dfcad 100644 --- a/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.test.tsx +++ b/app/component-library/components-temp/Tabs/TabsIconList/TabsIconList.test.tsx @@ -47,7 +47,7 @@ describe('TabsIconList', () => { Portfolio Content @@ -68,7 +68,7 @@ describe('TabsIconList', () => { Content 1 @@ -90,7 +90,7 @@ describe('TabsIconList', () => { @@ -115,7 +115,7 @@ describe('TabsIconList', () => { @@ -140,7 +140,7 @@ describe('TabsIconList', () => { Content 1 @@ -156,7 +156,7 @@ describe('TabsIconList', () => { Portfolio Content @@ -180,7 +180,7 @@ describe('TabsIconList', () => { Content 1 @@ -207,7 +207,7 @@ describe('TabsIconList', () => { Content 1 @@ -237,7 +237,7 @@ describe('TabsIconList', () => { Content 1 @@ -265,7 +265,7 @@ describe('TabsIconList', () => { Content 1 @@ -296,7 +296,7 @@ describe('TabsIconList', () => { Content 1 @@ -326,7 +326,7 @@ describe('TabsIconList', () => { Content 1 @@ -359,7 +359,7 @@ describe('TabsIconList', () => { Content 1 @@ -395,7 +395,7 @@ describe('TabsIconList', () => { Content 1 @@ -420,7 +420,7 @@ describe('TabsIconList', () => { Content 1 @@ -444,7 +444,7 @@ describe('TabsIconList', () => { Content 1 @@ -474,7 +474,7 @@ describe('TabsIconList', () => { { key: 'p-tab', label: 'Portfolio', - icon: IconName.Portfolio, + icon: IconName.PieChart, content: 'Portfolio Content', }, { @@ -526,7 +526,7 @@ describe('TabsIconList', () => { { key: 'p-tab', label: 'Portfolio', - icon: IconName.Portfolio, + icon: IconName.PieChart, content: 'Portfolio Content', }, { @@ -555,7 +555,7 @@ describe('TabsIconList', () => { Portfolio Content diff --git a/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.test.tsx b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.test.tsx index 6789e8f0e0b..52bafa0e5ca 100644 --- a/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.test.tsx +++ b/app/component-library/components-temp/Tabs/TabsIconTab/TabsIconTab.test.tsx @@ -9,7 +9,7 @@ import { IconName } from '../../../components/Icons/Icon/Icon.types'; describe('TabsIconTab', () => { const defaultProps = { label: 'Portfolio', - iconName: IconName.Portfolio, + iconName: IconName.PieChart, isActive: false, onPress: jest.fn(), }; @@ -108,7 +108,7 @@ describe('TabsIconTab', () => { describe('Icon', () => { it('renders all supported icon names without throwing', () => { const icons: IconName[] = [ - IconName.Portfolio, + IconName.PieChart, IconName.Candlestick, IconName.Predictions, ]; diff --git a/app/component-library/components/Icons/Icon/Icon.assets.ts b/app/component-library/components/Icons/Icon/Icon.assets.ts index 362637579fd..97625ce0070 100644 --- a/app/component-library/components/Icons/Icon/Icon.assets.ts +++ b/app/component-library/components/Icons/Icon/Icon.assets.ts @@ -167,6 +167,8 @@ import monitorSVG from './assets/monitor.svg'; import morehorizontalSVG from './assets/more-horizontal.svg'; import moreverticalSVG from './assets/more-vertical.svg'; import mountainflagSVG from './assets/mountain-flag.svg'; +import musdfilledSVG from './assets/musd-filled.svg'; +import musdSVG from './assets/musd.svg'; import musicnoteSVG from './assets/music-note.svg'; import nophotographySVG from './assets/no-photography.svg'; import notificationSVG from './assets/notification.svg'; @@ -176,13 +178,13 @@ import passwordcheckSVG from './assets/password-check.svg'; import pendingSVG from './assets/pending.svg'; import peopleSVG from './assets/people.svg'; import personcancelSVG from './assets/person-cancel.svg'; +import piechartSVG from './assets/pie-chart.svg'; import pinSVG from './assets/pin.svg'; import plantSVG from './assets/plant.svg'; -import pieChartSVG from './assets/pie-chart.svg'; -import predictionsSVG from './assets/predictions.svg'; import plugSVG from './assets/plug.svg'; import plusandminusSVG from './assets/plus-and-minus.svg'; import policyalertSVG from './assets/policy-alert.svg'; +import predictionsSVG from './assets/predictions.svg'; import printSVG from './assets/print.svg'; import priorityhighSVG from './assets/priority-high.svg'; import privacytipSVG from './assets/privacy-tip.svg'; @@ -454,6 +456,8 @@ export const assetByIconName: AssetByIconName = { [IconName.MoreHorizontal]: morehorizontalSVG, [IconName.MoreVertical]: moreverticalSVG, [IconName.MountainFlag]: mountainflagSVG, + [IconName.MusdFilled]: musdfilledSVG, + [IconName.Musd]: musdSVG, [IconName.MusicNote]: musicnoteSVG, [IconName.NoPhotography]: nophotographySVG, [IconName.Notification]: notificationSVG, @@ -463,13 +467,13 @@ export const assetByIconName: AssetByIconName = { [IconName.Pending]: pendingSVG, [IconName.People]: peopleSVG, [IconName.PersonCancel]: personcancelSVG, + [IconName.PieChart]: piechartSVG, [IconName.Pin]: pinSVG, [IconName.Plant]: plantSVG, - [IconName.Portfolio]: pieChartSVG, - [IconName.Predictions]: predictionsSVG, [IconName.Plug]: plugSVG, [IconName.PlusAndMinus]: plusandminusSVG, [IconName.PolicyAlert]: policyalertSVG, + [IconName.Predictions]: predictionsSVG, [IconName.Print]: printSVG, [IconName.PriorityHigh]: priorityhighSVG, [IconName.PrivacyTip]: privacytipSVG, diff --git a/app/component-library/components/Icons/Icon/Icon.types.ts b/app/component-library/components/Icons/Icon/Icon.types.ts index 63a92415a65..649011ddab5 100644 --- a/app/component-library/components/Icons/Icon/Icon.types.ts +++ b/app/component-library/components/Icons/Icon/Icon.types.ts @@ -237,6 +237,8 @@ export enum IconName { MoreHorizontal = 'MoreHorizontal', MoreVertical = 'MoreVertical', MountainFlag = 'MountainFlag', + MusdFilled = 'MusdFilled', + Musd = 'Musd', MusicNote = 'MusicNote', NoPhotography = 'NoPhotography', Notification = 'Notification', @@ -246,13 +248,13 @@ export enum IconName { Pending = 'Pending', People = 'People', PersonCancel = 'PersonCancel', + PieChart = 'PieChart', Pin = 'Pin', Plant = 'Plant', - Portfolio = 'Portfolio', - Predictions = 'Predictions', Plug = 'Plug', PlusAndMinus = 'PlusAndMinus', PolicyAlert = 'PolicyAlert', + Predictions = 'Predictions', Print = 'Print', PriorityHigh = 'PriorityHigh', PrivacyTip = 'PrivacyTip', diff --git a/app/component-library/components/Icons/Icon/assets/musd-filled.svg b/app/component-library/components/Icons/Icon/assets/musd-filled.svg new file mode 100644 index 00000000000..d960542501c --- /dev/null +++ b/app/component-library/components/Icons/Icon/assets/musd-filled.svg @@ -0,0 +1 @@ + diff --git a/app/component-library/components/Icons/Icon/assets/musd.svg b/app/component-library/components/Icons/Icon/assets/musd.svg new file mode 100644 index 00000000000..f65d9cc79cd --- /dev/null +++ b/app/component-library/components/Icons/Icon/assets/musd.svg @@ -0,0 +1 @@ + diff --git a/app/component-library/components/Navigation/TabBar/TabBar.constants.ts b/app/component-library/components/Navigation/TabBar/TabBar.constants.ts index 619bcb37456..6faa1e92b11 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.constants.ts +++ b/app/component-library/components/Navigation/TabBar/TabBar.constants.ts @@ -14,7 +14,7 @@ export const ICON_BY_TAB_BAR_ICON_KEY: IconByTabBarIconKey = { [TabBarIconKey.Setting]: IconName.Setting, [TabBarIconKey.Rewards]: IconName.MetamaskFoxOutline, [TabBarIconKey.Trending]: IconName.Search, - [TabBarIconKey.Money]: IconName.Bank, + [TabBarIconKey.Money]: IconName.Musd, }; export const LABEL_BY_TAB_BAR_ICON_KEY = { diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx index f3d8b1a8f70..10ccc18c8c2 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx @@ -34,7 +34,7 @@ const FILLED_ICONS: Partial> = { [TabBarIconKey.Activity]: IconName.ClockFilled, [TabBarIconKey.Trending]: IconName.Search, [TabBarIconKey.Rewards]: IconName.MetamaskFoxFilled, - [TabBarIconKey.Money]: IconName.Bank, + [TabBarIconKey.Money]: IconName.MusdFilled, }; const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index c7a322f2ab1..fe3c4f4082f 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -47,7 +47,11 @@ import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; /* eslint-disable-next-line */ import { NavigationContext } from '@react-navigation/core'; import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig'; -import { clearStackNavigatorOptions } from '../../../../constants/navigation/clearStackNavigatorOptions'; +import { + clearNativeStackNavigatorOptions, + clearStackNavigatorOptions, + transparentModalScreenOptions, +} from '../../../../constants/navigation/clearStackNavigatorOptions'; const Stack = createNativeStackNavigator(); const ModalStack = createStackNavigator(); @@ -73,7 +77,7 @@ function getRedesignedConfirmationsHeaderOptions({ title: '', headerBackVisible: false, contentStyle: { backgroundColor: 'transparent' }, - presentation: 'transparentModal', + ...transparentModalScreenOptions, }; } @@ -353,9 +357,9 @@ const PerpsScreenStack = () => { name={Routes.PERPS.TPSL} component={PerpsTPSLView} options={{ + ...transparentModalScreenOptions, title: strings('perps.tpsl.title'), headerShown: false, - presentation: 'transparentModal', }} /> @@ -411,12 +415,8 @@ const PerpsScreenStack = () => { name={Routes.PERPS.MODALS.CLOSE_POSITION_MODALS} component={PerpsClosePositionBottomSheetStack} options={{ - headerShown: false, - contentStyle: { - backgroundColor: 'transparent', - }, - animation: 'none', - presentation: 'transparentModal', + ...clearNativeStackNavigatorOptions, + ...transparentModalScreenOptions, }} /> @@ -425,12 +425,8 @@ const PerpsScreenStack = () => { name={Routes.PERPS.MODALS.ROOT} component={PerpsModalStack} options={{ - headerShown: false, - contentStyle: { - backgroundColor: 'transparent', - }, - animation: 'none', - presentation: 'transparentModal', + ...clearNativeStackNavigatorOptions, + ...transparentModalScreenOptions, }} /> @@ -442,7 +438,7 @@ const PerpsScreenStack = () => { component={PayWithModal} options={{ headerShown: false, - presentation: 'transparentModal', + ...transparentModalScreenOptions, }} /> diff --git a/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts index 0f13d88bcaf..3c7982808f0 100644 --- a/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts +++ b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts @@ -146,13 +146,13 @@ function setupDefaults({ subscriptionId = SUBSCRIPTION_ID, outcome = null, }: { - campaigns?: ReturnType[]; + campaigns?: ReturnType[] | undefined; dismissed?: Record; subscriptionId?: string | null; outcome?: BaseCampaignParticipantOutcomeDto | null; } = {}) { mockUseSelector.mockImplementation((selector) => { - if (selector === selectCampaigns) return campaigns; + if (selector === selectCampaigns) return campaigns ?? []; if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; if (selector === selectRewardsSubscriptionId) return subscriptionId; return undefined; @@ -193,6 +193,14 @@ describe('useCampaignOutcomeToast', () => { expect(mockShowToast).not.toHaveBeenCalled(); }); + it('campaigns are missing from persisted state', () => { + setupDefaults({ campaigns: undefined }); + expect(() => + renderHook(() => useCampaignOutcomeToast(mockConfig)), + ).not.toThrow(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + it('subscriptionId is missing', () => { setupDefaults({ subscriptionId: null, diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx index 51c2f1ff9bc..90f1fe15198 100644 --- a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx +++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx @@ -284,7 +284,7 @@ const HomepageDiscoveryTabs = forwardRef< style: { zIndex: 2 }, }} > - + = { }, }; -/** - * Get the default state for the RewardsController - */ -export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ - activeAccount: null, - accounts: {}, - subscriptions: {}, - subscriptionBenefits: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - unlockedRewards: {}, - pointsEvents: {}, - offDeviceSubscriptionAccounts: {}, - campaigns: {}, - campaignParticipantStatus: {}, - ondoCampaignLeaderboard: {}, - ondoCampaignLeaderboardPositions: {}, - ondoCampaignPortfolio: {}, - ondoCampaignActivity: {}, - ondoCampaignDeposits: {}, - perpsTradingCampaignLeaderboard: {}, - perpsTradingCampaignLeaderboardPositions: {}, - perpsTradingCampaignVolume: {}, - pointsEstimateHistory: [], - rewardsEnvUrl: null, -}); - -export const defaultRewardsControllerState = getRewardsControllerDefaultState(); +export { defaultRewardsControllerState, getRewardsControllerDefaultState }; type CacheReader = ( key: string, diff --git a/app/core/Engine/controllers/rewards-controller/defaultState.ts b/app/core/Engine/controllers/rewards-controller/defaultState.ts new file mode 100644 index 00000000000..16d1033d0ab --- /dev/null +++ b/app/core/Engine/controllers/rewards-controller/defaultState.ts @@ -0,0 +1,32 @@ +import type { RewardsControllerState } from './types'; + +/** + * Get the default state for the RewardsController. + */ +export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ + activeAccount: null, + accounts: {}, + subscriptions: {}, + subscriptionBenefits: {}, + seasons: {}, + subscriptionReferralDetails: {}, + seasonStatuses: {}, + activeBoosts: {}, + unlockedRewards: {}, + pointsEvents: {}, + offDeviceSubscriptionAccounts: {}, + campaigns: {}, + campaignParticipantStatus: {}, + ondoCampaignLeaderboard: {}, + ondoCampaignLeaderboardPositions: {}, + ondoCampaignPortfolio: {}, + ondoCampaignActivity: {}, + ondoCampaignDeposits: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, + pointsEstimateHistory: [], + rewardsEnvUrl: null, +}); + +export const defaultRewardsControllerState = getRewardsControllerDefaultState(); diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 59079e538a4..fc5b0514d26 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -2328,6 +2328,27 @@ describe('rewardsReducer', () => { ); }); + it('should default persisted season arrays to empty arrays when absent', () => { + const persistedRewardsStateWithoutFields = { + ...initialState, + seasonTiers: undefined, + seasonActivityTypes: undefined, + seasonWaysToEarn: undefined, + } as unknown as RewardsState; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { + rewards: persistedRewardsStateWithoutFields, + }, + }; + + const state = rewardsReducer(initialState, rehydrateAction); + + expect(state.seasonTiers).toEqual([]); + expect(state.seasonActivityTypes).toEqual([]); + expect(state.seasonWaysToEarn).toEqual([]); + }); + it('should preserve all persisted UI state fields', () => { // Arrange const persistedRewardsState: RewardsState = { @@ -2681,6 +2702,21 @@ describe('rewardsReducer', () => { expect(state.ondoCampaignActivity).toEqual({}); }); + + it('should default campaigns to [] when absent from persisted state (upgrade path)', () => { + const persistedRewardsStateWithoutField = { + ...initialState, + campaigns: undefined, + } as unknown as RewardsState; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { rewards: persistedRewardsStateWithoutField }, + }; + + const state = rewardsReducer(initialState, rehydrateAction); + + expect(state.campaigns).toEqual([]); + }); }); describe('unknown actions', () => { @@ -4623,6 +4659,17 @@ describe('setBenefits', () => { expect(state.benefits).toEqual(mockBenefitsPayload.benefits); }); + it('sets benefits to empty array when payload benefits are missing', () => { + const action = setBenefits({ + ...mockBenefitsPayload, + benefits: undefined, + } as unknown as typeof mockBenefitsPayload); + + const state = rewardsReducer(initialState, action); + + expect(state.benefits).toEqual([]); + }); + it('replaces existing benefits with new payload benefits', () => { const stateWithBenefits: RewardsState = { ...initialState, diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index e35e437aaa3..af29b06ca57 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -687,7 +687,7 @@ const rewardsSlice = createSlice({ }, setBenefits: (state, action: PayloadAction) => { - state.benefits = action.payload.benefits; + state.benefits = action.payload.benefits ?? []; }, setBenefitsLoading: (state, action: PayloadAction) => { @@ -897,9 +897,10 @@ const rewardsSlice = createSlice({ seasonName: action.payload.rewards.seasonName, seasonStartDate: action.payload.rewards.seasonStartDate, seasonEndDate: action.payload.rewards.seasonEndDate, - seasonTiers: action.payload.rewards.seasonTiers, - seasonActivityTypes: action.payload.rewards.seasonActivityTypes, - seasonWaysToEarn: action.payload.rewards.seasonWaysToEarn, + seasonTiers: action.payload.rewards.seasonTiers ?? [], + seasonActivityTypes: + action.payload.rewards.seasonActivityTypes ?? [], + seasonWaysToEarn: action.payload.rewards.seasonWaysToEarn ?? [], referralCode: action.payload.rewards.referralCode, refereeCount: action.payload.rewards.refereeCount, currentTier: action.payload.rewards.currentTier, @@ -912,7 +913,7 @@ const rewardsSlice = createSlice({ activeBoosts: action.payload.rewards.activeBoosts, pointsEvents: action.payload.rewards.pointsEvents, unlockedRewards: action.payload.rewards.unlockedRewards, - campaigns: action.payload.rewards.campaigns, + campaigns: action.payload.rewards.campaigns ?? [], campaignParticipantStatuses: action.payload.rewards.campaignParticipantStatuses ?? {}, ondoCampaignLeaderboardPositions: diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index 9519b4cb778..3cb37ace0b3 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -46,6 +46,7 @@ import { selectBulkLinkFailedAccounts, selectBulkLinkWasInterrupted, selectBulkLinkAccountProgress, + selectBenefits, selectCampaigns, selectCampaignsLoading, selectCampaignsError, @@ -85,6 +86,7 @@ import { SeasonWayToEarnDto, PointsEventDto, OndoGmActivityEntryDto, + SubscriptionBenefitDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { RootState } from '..'; import { RewardsState, AccountOptInBannerInfoStatus } from '.'; @@ -550,6 +552,16 @@ describe('Rewards selectors', () => { expect(result.current).toEqual([]); }); + it('returns empty array when season tiers are undefined', () => { + const mockState = { + rewards: { seasonTiers: undefined }, + } as unknown as RootState; + mockedUseSelector.mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => useSelector(selectSeasonTiers)); + expect(result.current).toEqual([]); + }); + it('returns season tiers when set', () => { const mockTiers: SeasonTierDto[] = [ { @@ -605,6 +617,18 @@ describe('Rewards selectors', () => { expect(result.current).toEqual([]); }); + it('returns empty array when season activity types are undefined', () => { + const mockState = { + rewards: { seasonActivityTypes: undefined }, + } as unknown as RootState; + mockedUseSelector.mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => + useSelector(selectSeasonActivityTypes), + ); + expect(result.current).toEqual([]); + }); + it('returns season activity types when set', () => { const mockActivityTypes: SeasonActivityTypeDto[] = [ { @@ -639,6 +663,16 @@ describe('Rewards selectors', () => { expect(result.current).toEqual([]); }); + it('returns empty array when season ways to earn are undefined', () => { + const mockState = { + rewards: { seasonWaysToEarn: undefined }, + } as unknown as RootState; + mockedUseSelector.mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => useSelector(selectSeasonWaysToEarn)); + expect(result.current).toEqual([]); + }); + it('returns season ways to earn when set', () => { const mockWaysToEarn: SeasonWayToEarnDto[] = [ { @@ -1033,6 +1067,18 @@ describe('Rewards selectors', () => { expect(result.current).toHaveLength(0); }); + it('returns empty array when account banner config is undefined', () => { + const mockState = { + rewards: { hideCurrentAccountNotOptedInBanner: undefined }, + } as unknown as RootState; + mockedUseSelector.mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => + useSelector(selectHideCurrentAccountNotOptedInBannerArray), + ); + expect(result.current).toEqual([]); + }); + it('returns single account configuration when set', () => { const mockAccountConfig: AccountOptInBannerInfoStatus = { accountGroupId: 'keyring:wallet1/1', @@ -3120,6 +3166,37 @@ describe('Rewards selectors', () => { showUpcomingDate: false, }; + describe('selectBenefits', () => { + const mockBenefit: SubscriptionBenefitDto = { + id: 101, + longTitle: 'Premium Access', + shortDescription: 'Get premium perks', + longDescription: 'Unlock premium partner benefits.', + thumbnail: 'https://example.com/benefits/premium.png', + validFrom: '2026-01-01T00:00:00.000Z', + validTo: '2026-12-31T00:00:00.000Z', + actionDate: '2026-06-01T00:00:00.000Z', + url: 'https://example.com/claim', + chain: 'ethereum', + type: { + id: 1, + name: 'Partner', + }, + }; + + it('returns empty array when benefits are undefined', () => { + const state = createMockRootState({ + benefits: undefined as unknown as SubscriptionBenefitDto[], + }); + expect(selectBenefits(state)).toEqual([]); + }); + + it('returns benefits when they exist', () => { + const state = createMockRootState({ benefits: [mockBenefit] }); + expect(selectBenefits(state)).toEqual([mockBenefit]); + }); + }); + describe('selectCampaigns', () => { it('returns empty array when campaigns is empty', () => { const mockState = { rewards: { campaigns: [] } }; @@ -3129,6 +3206,16 @@ describe('Rewards selectors', () => { expect(result.current).toEqual([]); }); + it('returns empty array when campaigns is undefined', () => { + const mockState = { + rewards: { campaigns: undefined }, + } as unknown as RootState; + mockedUseSelector.mockImplementation((selector) => selector(mockState)); + + const { result } = renderHook(() => useSelector(selectCampaigns)); + expect(result.current).toEqual([]); + }); + it('returns campaigns array when campaigns exist', () => { const mockState = { rewards: { campaigns: [mockCampaign] } }; mockedUseSelector.mockImplementation((selector) => selector(mockState)); @@ -3143,6 +3230,13 @@ describe('Rewards selectors', () => { expect(selectCampaigns(state)).toEqual([]); }); + it('returns empty array when campaigns is undefined', () => { + const state = createMockRootState({ + campaigns: undefined as unknown as CampaignDto[], + }); + expect(selectCampaigns(state)).toEqual([]); + }); + it('returns campaigns when they exist', () => { const state = createMockRootState({ campaigns: [mockCampaign] }); expect(selectCampaigns(state)).toEqual([mockCampaign]); @@ -3861,6 +3955,16 @@ describe('Rewards selectors', () => { expect(selectDismissedCampaignOutcomeToasts(state)).toEqual({}); }); + it('returns empty object when dismissed toasts are undefined', () => { + const state = createMockRootState({ + dismissedCampaignOutcomeToasts: undefined as unknown as Record< + string, + boolean + >, + }); + expect(selectDismissedCampaignOutcomeToasts(state)).toEqual({}); + }); + it('returns the dismissed toasts map', () => { const dismissed = { 'campaign-1:sub-1:winner': true, diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index 83c12f18cc9..fce1475bf10 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -1,8 +1,8 @@ import { createSelector } from 'reselect'; -import { RootState } from '..'; +import type { RootState } from '..'; +import { initialState } from '.'; import { RewardsTab, OnboardingStep } from './types'; import { hasMinimumRequiredVersion } from '../../util/remoteFeatureFlag'; -import { SubscriptionBenefitDto } from '../../core/Engine/controllers/rewards-controller/types.ts'; export const selectActiveTab = (state: RootState): RewardsTab => state.rewards.activeTab; @@ -49,14 +49,20 @@ export const selectSeasonStartDate = (state: RootState) => export const selectSeasonEndDate = (state: RootState) => state.rewards.seasonEndDate; -export const selectSeasonTiers = (state: RootState) => - state.rewards.seasonTiers; +export const selectSeasonTiers = ( + state: RootState, +): RootState['rewards']['seasonTiers'] => + state.rewards.seasonTiers ?? initialState.seasonTiers; -export const selectSeasonActivityTypes = (state: RootState) => - state.rewards.seasonActivityTypes; +export const selectSeasonActivityTypes = ( + state: RootState, +): RootState['rewards']['seasonActivityTypes'] => + state.rewards.seasonActivityTypes ?? initialState.seasonActivityTypes; -export const selectSeasonWaysToEarn = (state: RootState) => - state.rewards.seasonWaysToEarn; +export const selectSeasonWaysToEarn = ( + state: RootState, +): RootState['rewards']['seasonWaysToEarn'] => + state.rewards.seasonWaysToEarn ?? initialState.seasonWaysToEarn; export const selectOnboardingActiveStep = (state: RootState): OnboardingStep => state.rewards.onboardingActiveStep; @@ -90,7 +96,9 @@ export const selectHideUnlinkedAccountsBanner = (state: RootState) => export const selectHideCurrentAccountNotOptedInBannerArray = ( state: RootState, -) => state.rewards.hideCurrentAccountNotOptedInBanner; +): RootState['rewards']['hideCurrentAccountNotOptedInBanner'] => + state.rewards.hideCurrentAccountNotOptedInBanner ?? + initialState.hideCurrentAccountNotOptedInBanner; export const selectActiveBoosts = (state: RootState) => state.rewards.activeBoosts; @@ -152,14 +160,19 @@ export const selectBulkLinkAccountProgress = (state: RootState) => { }; // Benefits selectors -export const selectBenefits = (state: RootState): SubscriptionBenefitDto[] => - state.rewards.benefits; +export const selectBenefits = ( + state: RootState, +): RootState['rewards']['benefits'] => + state.rewards.benefits ?? initialState.benefits; export const selectBenefitsLoading = (state: RootState): boolean => state.rewards.benefitsLoading; // Campaigns selectors -export const selectCampaigns = (state: RootState) => state.rewards.campaigns; +export const selectCampaigns = ( + state: RootState, +): RootState['rewards']['campaigns'] => + state.rewards.campaigns ?? initialState.campaigns; export const selectCampaignById = (campaignId: string) => (state: RootState) => state.rewards.campaigns?.find((c) => c.id === campaignId) ?? null; @@ -317,8 +330,11 @@ export const selectOndoCampaignDepositsError = (state: RootState) => export const selectPendingDeeplink = (state: RootState) => state.rewards.pendingDeeplink; -export const selectDismissedCampaignOutcomeToasts = (state: RootState) => - state.rewards.dismissedCampaignOutcomeToasts; +export const selectDismissedCampaignOutcomeToasts = ( + state: RootState, +): RootState['rewards']['dismissedCampaignOutcomeToasts'] => + state.rewards.dismissedCampaignOutcomeToasts ?? + initialState.dismissedCampaignOutcomeToasts; // Perps Trading Campaign leaderboard selectors export const selectPerpsTradingCampaignLeaderboard = (state: RootState) => diff --git a/app/selectors/rewards/index.test.ts b/app/selectors/rewards/index.test.ts index 40c19bc2197..6d2db2a7f16 100644 --- a/app/selectors/rewards/index.test.ts +++ b/app/selectors/rewards/index.test.ts @@ -65,7 +65,13 @@ describe('Rewards Selectors', () => { const result = selectRewardsControllerState(state); // Assert - expect(result).toBeUndefined(); + expect(result).toEqual( + expect.objectContaining({ + activeAccount: null, + accounts: {}, + subscriptions: {}, + }), + ); }); }); @@ -177,6 +183,24 @@ describe('Rewards Selectors', () => { // Assert expect(result).toBeNull(); }); + + it('returns null when RewardsController is undefined', () => { + // Arrange + const state = { + engine: { + backgroundState: { + RewardsController: undefined, + }, + }, + rewards: { candidateSubscriptionId: null }, + } as unknown as RootState; + + // Act + const result = selectRewardsSubscriptionId(state); + + // Assert + expect(result).toBeNull(); + }); }); describe('selectRewardsActiveAccountAddress', () => { @@ -249,6 +273,23 @@ describe('Rewards Selectors', () => { expect(result).toBeNull(); }); + it('returns null when RewardsController is undefined', () => { + // Arrange + const state = { + engine: { + backgroundState: { + RewardsController: undefined, + }, + }, + } as unknown as RootState; + + // Act + const result = selectRewardsActiveAccountAddress(state); + + // Assert + expect(result).toBeNull(); + }); + it('returns null when active account has no account property', () => { // Arrange const state = createMockRootState({ @@ -395,6 +436,49 @@ describe('Rewards Selectors', () => { expect(result).toHaveLength(0); }); + it('returns empty array when controller accounts are missing', () => { + const state = { + engine: { + backgroundState: { + RewardsController: { + activeAccount: { subscriptionId: 'sub-1' }, + accounts: undefined, + subscriptions: { + 'sub-1': { id: 'sub-1' }, + }, + }, + }, + }, + rewards: { candidateSubscriptionId: null }, + } as unknown as RootState; + + const result = selectCurrentSubscriptionAccounts(state); + expect(result).toEqual([]); + }); + + it('returns empty array when controller subscriptions are missing', () => { + const state = { + engine: { + backgroundState: { + RewardsController: { + activeAccount: { subscriptionId: 'sub-1' }, + accounts: { + 'acct-1': { + subscriptionId: 'sub-1', + account: 'eip155:1:0x123', + }, + }, + subscriptions: undefined, + }, + }, + }, + rewards: { candidateSubscriptionId: null }, + } as unknown as RootState; + + const result = selectCurrentSubscriptionAccounts(state); + expect(result).toEqual([]); + }); + it('returns empty array when no accounts match subscription', () => { const state = { engine: { diff --git a/app/selectors/rewards/index.ts b/app/selectors/rewards/index.ts index ca99456407f..c91b88dc338 100644 --- a/app/selectors/rewards/index.ts +++ b/app/selectors/rewards/index.ts @@ -1,14 +1,21 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '../../reducers'; -import { RewardsAccountState } from '../../core/Engine/controllers/rewards-controller/types'; +import type { + RewardsAccountState, + RewardsControllerState, +} from '../../core/Engine/controllers/rewards-controller/types'; +import { defaultRewardsControllerState } from '../../core/Engine/controllers/rewards-controller/defaultState'; /** * * @param state - Root redux state * @returns - AccountsController state */ -export const selectRewardsControllerState = (state: RootState) => - state.engine.backgroundState.RewardsController; +export const selectRewardsControllerState = ( + state: RootState, +): RewardsControllerState => + state.engine.backgroundState.RewardsController ?? + defaultRewardsControllerState; export const selectRewardsActiveAccountSubscriptionId = createSelector( selectRewardsControllerState, @@ -57,14 +64,14 @@ export const selectCurrentSubscription = createSelector( [selectRewardsSubscriptionId, selectRewardsControllerState], (subscriptionId, rewardsState) => subscriptionId - ? (rewardsState.subscriptions[subscriptionId] ?? null) + ? (rewardsState.subscriptions?.[subscriptionId] ?? null) : null, ); export const selectCurrentSubscriptionAccounts = createSelector( [selectRewardsControllerState, selectCurrentSubscription], (rewardsState, subscription) => - Object.values(rewardsState.accounts).filter( + Object.values(rewardsState.accounts ?? {}).filter( (account: RewardsAccountState) => account.subscriptionId === subscription?.id, ),