Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,622 changes: 171 additions & 1,451 deletions app/components/UI/Rewards/Views/RewardsDashboard.test.tsx

Large diffs are not rendered by default.

87 changes: 3 additions & 84 deletions app/components/UI/Rewards/Views/RewardsDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import React, {
useEffect,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { Box, IconName } from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
Expand All @@ -19,28 +13,18 @@ import {
selectActiveTab,
selectHideUnlinkedAccountsBanner,
selectHideCurrentAccountNotOptedInBannerArray,
selectSeasonId,
selectOptinAllowedForGeo,
} from '../../../../reducers/rewards/selectors';
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary';
import {
useRewardDashboardModals,
RewardsDashboardModalType,
} from '../hooks/useRewardDashboardModals';
import { useBulkLinkState } from '../hooks/useBulkLinkState';
import MusdCalculatorTab from '../components/Tabs/MusdCalculatorTab/MusdCalculatorTab';
import { TabsList } from '../../../../component-library/components-temp/Tabs';
import {
TabsListRef,
TabViewProps,
} from '../../../../component-library/components-temp/Tabs/TabsList/TabsList.types';
import Toast from '../../../../component-library/components/Toast';
import { ToastRef } from '../../../../component-library/components/Toast/Toast.types';
import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics';
import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController';
import PreviousSeasonSummary from '../components/PreviousSeason/PreviousSeasonSummary';
import CampaignsPreview from '../components/Campaigns/CampaignsPreview';
import EarnRewardsPreview from '../components/EarnRewards/EarnRewardsPreview';

Expand All @@ -55,9 +39,6 @@ const RewardsDashboard: React.FC = () => {
const hideUnlinkedAccountsBanner = useSelector(
selectHideUnlinkedAccountsBanner,
);
const seasonId = useSelector(selectSeasonId);
const optinAllowedForGeo = useSelector(selectOptinAllowedForGeo);
const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag);
const hideCurrentAccountNotOptedInBannerMap = useSelector(
selectHideCurrentAccountNotOptedInBannerArray,
);
Expand All @@ -73,11 +54,6 @@ const RewardsDashboard: React.FC = () => {
return false;
}, [selectedAccountGroup?.id, hideCurrentAccountNotOptedInBannerMap]);

const [showPreviousSeasonSummary, setShowPreviousSeasonSummary] = useState<
boolean | null
>(null);
const tabsListRef = useRef<TabsListRef>(null);

// Use the reward dashboard modals hook
const {
showUnlinkedAccountsModal,
Expand Down Expand Up @@ -127,26 +103,11 @@ const RewardsDashboard: React.FC = () => {
}, [wasInterrupted, isRunning, resumeBulkLink]),
);

// Evaluate showPreviousSeasonSummary when screen comes into focus
useFocusEffect(
useCallback(() => {
const shouldShow = Boolean(seasonId && !isCampaignsEnabled);
setShowPreviousSeasonSummary(shouldShow);
}, [seasonId, isCampaignsEnabled]),
);

// Auto-trigger dashboard modals based on account/rewards state (session-aware)
// This effect runs whenever key dependencies change and determines which informational
// modal should be shown to guide the user. Each modal type is only shown once per app session.
useFocusEffect(
useCallback(() => {
if (
!seasonId ||
showPreviousSeasonSummary === null ||
showPreviousSeasonSummary
)
return;

if (
(totalOptedInAccountsSelectedGroup === 0 ||
currentAccountGroupPartiallySupported === false) &&
Expand Down Expand Up @@ -182,8 +143,6 @@ const RewardsDashboard: React.FC = () => {
}
}
}, [
seasonId,
showPreviousSeasonSummary,
totalOptedInAccountsSelectedGroup,
currentAccountGroupPartiallySupported,
hideCurrentAccountNotOptedInBanner,
Expand Down Expand Up @@ -232,51 +191,11 @@ const RewardsDashboard: React.FC = () => {
disabled: !subscriptionId,
testID: REWARDS_VIEW_SELECTORS.SETTINGS_BUTTON,
},
...(showPreviousSeasonSummary === false
? [
{
iconName: IconName.UserCircleAdd,
onPress: () =>
navigation.navigate(Routes.REFERRAL_REWARDS_VIEW),
disabled: !subscriptionId,
testID: REWARDS_VIEW_SELECTORS.REFERRAL_BUTTON,
},
]
: []),
]}
/>
<Box twClassName="flex-1 gap-4">
{isCampaignsEnabled && <CampaignsPreview />}
{isCampaignsEnabled && <EarnRewardsPreview />}
{showPreviousSeasonSummary &&
(optinAllowedForGeo ? (
<TabsList
ref={tabsListRef}
initialActiveIndex={0}
testID={REWARDS_VIEW_SELECTORS.TAB_CONTROL}
tabsBarProps={{ twClassName: 'px-4' }}
tabsListContentTwClassName="px-0"
>
<Box
key="musd"
{...({ tabLabel: 'mUSD' } as TabViewProps)}
twClassName="flex-1"
>
<MusdCalculatorTab />
</Box>
<Box
key="previous-season"
{...({
tabLabel: strings('rewards.season_1'),
} as TabViewProps)}
twClassName="flex-1"
>
<PreviousSeasonSummary />
</Box>
</TabsList>
) : (
<PreviousSeasonSummary />
))}
<CampaignsPreview />
<EarnRewardsPreview />
</Box>
</SafeAreaView>
<Toast ref={toastRef} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,5 +335,46 @@ describe('CampaignTile', () => {
campaignId: 'camp-42',
});
});

it('calls custom onPress handler instead of navigating when provided', () => {
const campaign = createTestCampaign({ id: 'camp-custom' });
const mockOnPress = jest.fn();

const { getByTestId } = render(
<CampaignTile campaign={campaign} onPress={mockOnPress} />,
);
fireEvent.press(getByTestId('campaign-tile-camp-custom'));

expect(mockOnPress).toHaveBeenCalledTimes(1);
expect(mockNavigate).not.toHaveBeenCalled();
});

it('does not navigate when isInteractive is false', () => {
const campaign = createTestCampaign({ id: 'camp-disabled' });

const { getByTestId } = render(
<CampaignTile campaign={campaign} isInteractive={false} />,
);
fireEvent.press(getByTestId('campaign-tile-camp-disabled'));

expect(mockNavigate).not.toHaveBeenCalled();
});

it('does not call onPress when isInteractive is false', () => {
const campaign = createTestCampaign({ id: 'camp-both' });
const mockOnPress = jest.fn();

const { getByTestId } = render(
<CampaignTile
campaign={campaign}
isInteractive={false}
onPress={mockOnPress}
/>,
);
fireEvent.press(getByTestId('campaign-tile-camp-both'));

expect(mockOnPress).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
});
});
});
32 changes: 28 additions & 4 deletions app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,30 @@ import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipa

interface CampaignTileProps {
campaign: CampaignDto;
/**
* Whether the tile is interactive (pressable). Defaults to true.
* When false, the tile is displayed but cannot be tapped.
*/
isInteractive?: boolean;
/**
* Custom press handler. If provided, this is called instead of the default
* navigation to campaign details. Only used when isInteractive is true.
*/
onPress?: () => void;
}

/**
* CampaignTile displays campaign information with status.
* Tapping navigates to the campaign details screen.
* Tapping behavior can be customized via props:
* - Default: navigates to campaign details screen
* - With onPress: executes custom handler
* - With isInteractive=false: tile is not pressable
*/
const CampaignTile: React.FC<CampaignTileProps> = ({ campaign }) => {
const CampaignTile: React.FC<CampaignTileProps> = ({
campaign,
isInteractive = true,
onPress,
}) => {
const tw = useTailwind();
const colorScheme = useColorScheme();
const navigation = useNavigation();
Expand All @@ -58,16 +75,23 @@ const CampaignTile: React.FC<CampaignTileProps> = ({ campaign }) => {
: campaign.details?.image?.lightModeUrl;

const handlePress = () => {
navigation.navigate(Routes.CAMPAIGN_DETAILS, { campaignId: campaign.id });
if (!isInteractive) return;

if (onPress) {
onPress();
} else {
navigation.navigate(Routes.CAMPAIGN_DETAILS, { campaignId: campaign.id });
}
};

return (
<Pressable
onPress={handlePress}
disabled={!isInteractive}
style={({ pressed }) =>
tw.style(
'rounded-xl overflow-hidden h-50 bg-muted',
pressed && 'opacity-70',
pressed && isInteractive && 'opacity-70',
)
}
testID={`campaign-tile-${campaign.id}`}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
import CampaignTile from './CampaignTile';
import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types';
import PreviousSeasonTile from '../PreviousSeason/PreviousSeasonTile';
import { selectSeasonName } from '../../../../../reducers/rewards/selectors';
import { useSelector } from 'react-redux';
import { selectCampaignsRewardsEnabledFlag } from '../../../../../selectors/featureFlagController/rewards';

interface CampaignsGroupProps {
title: string;
Expand All @@ -23,6 +24,7 @@ const CampaignsGroup: React.FC<CampaignsGroupProps> = ({
displayPreviousSeason = false,
}) => {
const seasonName = useSelector(selectSeasonName);
const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag);
const showPreviousSeason = displayPreviousSeason && !!seasonName;

if (campaigns.length === 0 && !showPreviousSeason) {
Expand All @@ -35,7 +37,11 @@ const CampaignsGroup: React.FC<CampaignsGroupProps> = ({
{title}
</Text>
{campaigns.map((campaign) => (
<CampaignTile key={campaign.id} campaign={campaign} />
<CampaignTile
key={campaign.id}
campaign={campaign}
isInteractive={isCampaignsEnabled}
/>
))}
{showPreviousSeason && <PreviousSeasonTile />}
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: mockNavigate }),
}));

jest.mock('../../../../../selectors/featureFlagController/rewards', () => ({
selectCampaignsRewardsEnabledFlag: jest.fn(() => true),
}));

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (selector: (state: unknown) => unknown) => selector({}),
}));

jest.mock('@metamask/design-system-react-native', () => {
const actual = jest.requireActual('@metamask/design-system-react-native');
return { ...actual };
Expand Down Expand Up @@ -42,6 +51,20 @@ jest.mock('./CampaignTile', () => {
};
});

jest.mock('../PreviousSeason/PreviousSeasonTile', () => {
const ReactActual = jest.requireActual('react');
const { Text } = jest.requireActual('react-native');
return {
__esModule: true,
default: () =>
ReactActual.createElement(
Text,
{ testID: 'previous-season-tile' },
'Previous Season Tile',
),
};
});

jest.mock('../../../../../../locales/i18n', () => ({
strings: (key: string) => {
const translations: Record<string, string> = {
Expand Down Expand Up @@ -82,10 +105,27 @@ describe('CampaignsPreview', () => {
mockUseRewardCampaigns.mockReturnValue(mockHookDefaults);
});

it('returns null when there are no campaigns in any category', () => {
const { queryByTestId } = render(<CampaignsPreview />);
it('renders PreviousSeasonTile when there are no campaigns in any category', () => {
const { getByTestId } = render(<CampaignsPreview />);

expect(
getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW),
).toBeOnTheScreen();
expect(getByTestId('previous-season-tile')).toBeOnTheScreen();
});

it('renders PreviousSeasonTile when there is an error and no campaigns', () => {
mockUseRewardCampaigns.mockReturnValue({
...mockHookDefaults,
hasError: true,
});

const { getByTestId } = render(<CampaignsPreview />);

expect(queryByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW)).toBeNull();
expect(
getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW),
).toBeOnTheScreen();
expect(getByTestId('previous-season-tile')).toBeOnTheScreen();
});

it('renders the section title when an active campaign exists', () => {
Expand Down Expand Up @@ -248,4 +288,13 @@ describe('CampaignsPreview', () => {

expect(mockNavigate).toHaveBeenCalledWith(Routes.CAMPAIGNS_VIEW);
});

it('navigates to previous season view when title header is pressed and no campaigns exist', () => {
mockUseRewardCampaigns.mockReturnValue(mockHookDefaults);

const { getByText } = render(<CampaignsPreview />);
fireEvent.press(getByText('Campaigns'));

expect(mockNavigate).toHaveBeenCalledWith(Routes.PREVIOUS_SEASON_VIEW);
});
});
Loading
Loading