From 2ab507486809a6ed11f3e063503c7503316710f0 Mon Sep 17 00:00:00 2001 From: Rik Van Gulck Date: Mon, 4 May 2026 12:04:51 +0200 Subject: [PATCH 01/10] feat(rewards): add Perps Trading campaign participant outcome support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `PERPS_TRADING` to `CampaignType` enum - Add `PerpsTradingCampaignParticipantOutcomeDto` type - Add `getPerpsTradingCampaignParticipantOutcome` to data service, controller (10 min cache), action types, and messenger - Add `usePerpsTradingCampaignParticipantOutcome` hook for fetching outcome - Add `usePerpsTradingCampaignEndedOutcomeToast` hook: shows winner toast (→ winning view) or participant_finalized toast (→ campaigns) - Add `PerpsTradingCampaignWinningView` screen: displays rank and verification code, with copy/mail actions - Add `formatOrdinalRank` utility to formatUtils - Wire navigator with new screen and toast hook - Add en.json strings for toast and winning view Co-authored-by: VGR-GIT Co-authored-by: Claude Sonnet 4.6 fix(rewards): use COPY_WINNER_VERIFICATION_CODE metric for winning view copy action Replace the incorrect COPY_REFERRAL_CODE button type with a dedicated COPY_WINNER_VERIFICATION_CODE value in the analytics event fired when a user copies their verification code on the Perps Trading winning view. Co-authored-by: VGR-GIT Co-authored-by: Claude Sonnet 4.6 Reduce duplication Add test coverage Fix lint --- .../UI/Rewards/RewardsNavigator.test.tsx | 10 + .../UI/Rewards/RewardsNavigator.tsx | 12 +- .../Views/CampaignWinningView.test.tsx | 279 +++++++++ .../UI/Rewards/Views/CampaignWinningView.tsx | 211 +++++++ .../UI/Rewards/Views/CampaignsView.test.tsx | 16 - .../UI/Rewards/Views/CampaignsView.tsx | 2 - .../Rewards/Views/OndoCampaignStatsView.tsx | 4 +- .../Views/OndoCampaignWinningView.test.tsx | 466 ++++---------- .../Rewards/Views/OndoCampaignWinningView.tsx | 243 ++------ .../Views/PerpsTradingCampaignStatsView.tsx | 27 +- .../PerpsTradingCampaignWinningView.test.tsx | 159 +++++ .../Views/PerpsTradingCampaignWinningView.tsx | 87 +++ .../Rewards/Views/RewardsDashboard.test.tsx | 4 - .../UI/Rewards/Views/RewardsDashboard.tsx | 2 - ...st.tsx => CampaignOutcomeBanners.test.tsx} | 62 +- ...Banners.tsx => CampaignOutcomeBanners.tsx} | 51 +- .../OndoCampaignStatsSummary.test.tsx | 4 +- .../Campaigns/OndoCampaignStatsSummary.tsx | 4 +- .../hooks/useCampaignOutcomeToast.test.ts | 500 +++++++++++++++ .../Rewards/hooks/useCampaignOutcomeToast.ts | 174 ++++++ .../useCampaignParticipantOutcome.test.ts | 176 ++++++ .../hooks/useCampaignParticipantOutcome.ts | 68 +++ .../hooks/useLinkAccountAddress.test.ts | 8 + .../Rewards/hooks/useLinkAccountGroup.test.ts | 8 + .../useOndoCampaignParticipantOutcome.test.ts | 156 ++--- .../useOndoCampaignParticipantOutcome.ts | 59 +- .../Rewards/hooks/useOndoOutcomeToast.test.ts | 570 ++---------------- .../UI/Rewards/hooks/useOndoOutcomeToast.ts | 167 +---- ...psTradingCampaignEndedOutcomeToast.test.ts | 98 +++ ...sePerpsTradingCampaignEndedOutcomeToast.ts | 21 + ...sTradingCampaignParticipantOutcome.test.ts | 68 +++ ...ePerpsTradingCampaignParticipantOutcome.ts | 22 + .../UI/Rewards/hooks/useRewardsToast.test.tsx | 62 ++ .../UI/Rewards/hooks/useRewardsToast.tsx | 71 ++- app/components/UI/Rewards/utils.ts | 1 + app/constants/navigation/Routes.ts | 2 + .../RewardsController-method-action-types.ts | 14 + .../RewardsController.test.ts | 155 +++++ .../rewards-controller/RewardsController.ts | 64 ++ .../services/rewards-data-service.test.ts | 53 ++ .../services/rewards-data-service.ts | 30 +- .../controllers/rewards-controller/types.ts | 25 +- .../rewards-controller-messenger/index.ts | 5 +- app/reducers/rewards/index.test.ts | 40 +- app/reducers/rewards/index.ts | 2 +- app/reducers/rewards/selectors.test.ts | 10 +- locales/languages/de.json | 44 +- locales/languages/el.json | 44 +- locales/languages/en.json | 28 +- locales/languages/es.json | 44 +- locales/languages/fr.json | 44 +- locales/languages/hi.json | 44 +- locales/languages/id.json | 44 +- locales/languages/ja.json | 44 +- locales/languages/ko.json | 44 +- locales/languages/pt.json | 44 +- locales/languages/ru.json | 44 +- locales/languages/tl.json | 44 +- locales/languages/tr.json | 44 +- locales/languages/vi.json | 44 +- locales/languages/zh.json | 44 +- 61 files changed, 3086 insertions(+), 1830 deletions(-) create mode 100644 app/components/UI/Rewards/Views/CampaignWinningView.test.tsx create mode 100644 app/components/UI/Rewards/Views/CampaignWinningView.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx create mode 100644 app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx rename app/components/UI/Rewards/components/Campaigns/{OndoCampaignOutcomeBanners.test.tsx => CampaignOutcomeBanners.test.tsx} (67%) rename app/components/UI/Rewards/components/Campaigns/{OndoCampaignOutcomeBanners.tsx => CampaignOutcomeBanners.tsx} (53%) create mode 100644 app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts create mode 100644 app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts create mode 100644 app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts create mode 100644 app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts create mode 100644 app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts create mode 100644 app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts create mode 100644 app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts create mode 100644 app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index e903bafb8ec..4dae365e704 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -255,6 +255,14 @@ jest.mock('./hooks/useReferralDetails', () => ({ }), })); +jest.mock('./hooks/useOndoOutcomeToast', () => ({ + useOndoOutcomeToast: jest.fn(), +})); + +jest.mock('./hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); + // Mock useRewardsNotificationsNudge hook const mockShowEnableNotificationsNudge = jest.fn(() => false); const mockCloseEnableNotificationsNudge = jest.fn(); @@ -282,6 +290,8 @@ jest.mock('./hooks/useRewardsToast', () => ({ loading: jest.fn(), entriesClosed: jest.fn(), enableNotificationsNudge: jest.fn(), + outcomeWinner: jest.fn(), + outcomeNonWinner: jest.fn(), }, })), })); diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index f9aabffa47a..ce1300a8dc0 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -36,10 +36,12 @@ import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata'; import { useReferralDetails } from './hooks/useReferralDetails'; import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge'; import useRewardsToast from './hooks/useRewardsToast'; +import { useOndoOutcomeToast } from './hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from './hooks/usePerpsTradingCampaignEndedOutcomeToast'; import { strings } from '../../../../locales/i18n'; +import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView'; let sessionNotificationsNudgeShown = false; - const Stack = createStackNavigator(); const RewardsNavigator: React.FC = () => { @@ -74,6 +76,10 @@ const RewardsNavigator: React.FC = () => { // Fetch referral details so referral code is available across all rewards screens useReferralDetails(); + // Outcome toasts for all campaign types — mounted once so they are active regardless of which screen is focused + useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); + const { showToast, RewardsToastOptions } = useRewardsToast(); const nudgeToastActiveRef = useRef(false); @@ -294,6 +300,10 @@ const RewardsNavigator: React.FC = () => { + diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx new file mode 100644 index 00000000000..198cee4e545 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx @@ -0,0 +1,279 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import CampaignWinningView, { + CampaignWinningViewProps, +} from './CampaignWinningView'; +import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; + +jest.mock('../../../../images/rewards/campaign_winning.png', () => ({ + __esModule: true, + default: 1, +})); + +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; +}); + +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + default: () => ({ width: 390, height: 844 }), +})); + +jest.mock('react-native-safe-area-context', () => { + const actual = jest.requireActual('react-native-safe-area-context'); + return { + ...actual, + useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), + }; +}); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children), + }; +}); + +jest.mock('../hooks/useTrackRewardsPageView', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../../../core/Analytics', () => ({ + MetaMetricsEvents: { + REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED', + }, +})); + +jest.mock('../utils', () => ({ + RewardsMetricsButtons: { + COPY_WINNER_VERIFICATION_CODE: 'copy_winner_verification_code', + }, +})); + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({})); +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: () => ({ + addProperties: () => ({ build: mockBuild }), + }), + }), +})); + +jest.mock('../components/ReferralDetails/CopyableField', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + label, + value, + onCopy, + }: { + label: string; + value?: string | null; + onCopy?: () => void; + }) => + ReactActual.createElement( + View, + { testID: 'copyable-field' }, + ReactActual.createElement(Text, null, label), + ReactActual.createElement( + Text, + { testID: 'copyable-value' }, + value ?? '', + ), + ReactActual.createElement(Pressable, { + testID: 'copyable-trigger', + onPress: onCopy, + }), + ), + }; +}); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn( + ( + key: string, + params?: { + place?: string; + code?: string; + campaignName?: string; + email?: string; + }, + ) => { + if (key === 'rewards.campaign_winning.rank_label' && params?.place) + return `${params.place} place`; + if ( + key === 'rewards.campaign_winning.mail_subject' && + params?.campaignName + ) + return `${params.campaignName} prize claim`; + if (key === 'rewards.campaign_winning.mail_body' && params?.code) + return `My winning code: ${params.code}`; + if ( + key === 'rewards.campaign_winning.email_instructions' && + params?.email + ) + return `Email ${params.email} with your code`; + const map: Record = { + 'rewards.campaign_winning.you_won': 'You won', + 'rewards.campaign_winning.open_mail': 'Open mail', + 'rewards.campaign_winning.skip_for_now': 'Skip for now', + 'rewards.campaign_winning.winning_code': 'Winning code', + 'rewards.campaign_winning.close_a11y': 'Close', + }; + return map[key] ?? key; + }, + ), +})); + +const PRIZE_EMAIL = 'test@consensys.net'; +const CAMPAIGN_NAME = 'Test Campaign'; +const CAMPAIGN_ID = 'campaign-test-1'; +const WINNING_CODE = 'WIN-123'; +const mockUseTrackRewardsPageView = + useTrackRewardsPageView as jest.MockedFunction< + typeof useTrackRewardsPageView + >; + +const defaultProps: CampaignWinningViewProps = { + testID: 'test-winning-view', + viewName: 'TestWinningView', + prizeEmail: PRIZE_EMAIL, + campaignName: CAMPAIGN_NAME, + campaignId: CAMPAIGN_ID, + analyticsPageType: 'test_campaign_winning', + winningCode: WINNING_CODE, + hasOutcomeLoaded: true, + isLoading: false, + renderRankSection: () => null, +}; + +describe('CampaignWinningView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the main container with the provided testID', () => { + const { getByTestId } = render(); + expect(getByTestId('test-winning-view')).toBeTruthy(); + }); + + it('renders "You won" text', () => { + const { getByText } = render(); + expect(getByText('You won')).toBeTruthy(); + }); + + it('renders email instructions with the prizeEmail', () => { + const { getByText } = render(); + expect(getByText(`Email ${PRIZE_EMAIL} with your code`)).toBeTruthy(); + }); + + it('tracks page view with the campaign id', () => { + render(); + expect(mockUseTrackRewardsPageView).toHaveBeenCalledWith({ + page_type: 'test_campaign_winning', + campaign_id: CAMPAIGN_ID, + }); + }); + + it('renders the renderRankSection slot content', () => { + const { getByTestId } = render( + ( + + {/* eslint-disable-next-line react-native/no-inline-styles */} + + + )} + />, + ); + expect(getByTestId('test-winning-view')).toBeTruthy(); + }); + + it('calls goBack when Skip for now is pressed', () => { + const { getByText } = render(); + fireEvent.press(getByText('Skip for now')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('calls goBack when Close button is pressed', () => { + const { getByLabelText } = render( + , + ); + fireEvent.press(getByLabelText('Close')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('copies winning code and fires analytics when copy is triggered', () => { + const setStringSpy = jest.spyOn(Clipboard, 'setString'); + const { getByTestId } = render(); + fireEvent.press(getByTestId('copyable-trigger')); + expect(setStringSpy).toHaveBeenCalledWith(WINNING_CODE); + expect(mockTrackEvent).toHaveBeenCalled(); + setStringSpy.mockRestore(); + }); + + it('opens mailto with the correct email and code when Open mail is pressed', async () => { + const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + const { getByText } = render(); + fireEvent.press(getByText('Open mail')); + expect(openSpy).toHaveBeenCalled(); + const url = openSpy.mock.calls[0][0] as string; + expect(url).toContain(`mailto:${PRIZE_EMAIL}`); + expect(url).toContain(encodeURIComponent(WINNING_CODE)); + openSpy.mockRestore(); + }); + + it('calls goBack when outcome loads without a winning code', () => { + render( + , + ); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('does not call goBack before outcome has loaded', () => { + render( + , + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('does not call goBack while still loading', () => { + render( + , + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.tsx new file mode 100644 index 00000000000..2c40fce4fec --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignWinningView.tsx @@ -0,0 +1,211 @@ +import React, { useCallback, useEffect } from 'react'; +import { Image, Linking, ScrollView, useWindowDimensions } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { useNavigation } from '@react-navigation/native'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxFlexDirection, + Button, + ButtonSize, + ButtonVariant, + ButtonIcon, + ButtonIconSize, + IconName, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; +import { strings } from '../../../../../locales/i18n'; +import CopyableField from '../components/ReferralDetails/CopyableField'; +import { RewardsMetricsButtons } from '../utils'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import campaignWinningHero from '../../../../images/rewards/campaign_winning.png'; + +const HERO_HEIGHT_RATIO = 0.5; + +export interface CampaignWinningViewProps { + testID: string; + viewName: string; + prizeEmail: string; + campaignName: string; + campaignId: string; + analyticsPageType: string; + winningCode: string | null; + hasOutcomeLoaded: boolean; + isLoading: boolean; + renderRankSection: () => React.ReactNode; +} + +const CampaignWinningView: React.FC = ({ + testID, + viewName, + prizeEmail, + campaignName, + campaignId, + analyticsPageType, + winningCode, + hasOutcomeLoaded, + isLoading, + renderRankSection, +}) => { + const tw = useTailwind(); + const { height: windowHeight } = useWindowDimensions(); + const heroHeight = windowHeight * HERO_HEIGHT_RATIO; + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + useTrackRewardsPageView({ + page_type: analyticsPageType, + campaign_id: campaignId, + }); + + useEffect(() => { + if (!isLoading && hasOutcomeLoaded && winningCode === null) { + navigation.goBack(); + } + }, [isLoading, hasOutcomeLoaded, winningCode, navigation]); + + const onDismiss = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleCopyWinningCode = useCallback(() => { + if (winningCode) { + Clipboard.setString(winningCode); + trackEvent( + createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED) + .addProperties({ + button_type: RewardsMetricsButtons.COPY_WINNER_VERIFICATION_CODE, + }) + .build(), + ); + } + }, [winningCode, trackEvent, createEventBuilder]); + + const handleOpenMail = useCallback(async () => { + const baseSubject = strings('rewards.campaign_winning.mail_subject', { + campaignName, + }); + const subject = winningCode + ? `${baseSubject} - ${winningCode}` + : baseSubject; + const body = strings('rewards.campaign_winning.mail_body', { + code: winningCode || '—', + }); + const url = `mailto:${prizeEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + try { + await Linking.openURL(url); + } catch { + // no-op: device may not have a mail handler + } + }, [winningCode, prizeEmail, campaignName]); + + return ( + + + + + + + + + + + + + {strings('rewards.campaign_winning.you_won')} + + + {renderRankSection()} + + + {strings('rewards.campaign_winning.email_instructions', { + email: prizeEmail, + })} + + + + + + + + + + + + + + + + ); +}; + +export default CampaignWinningView; diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx index 0bca5c0bfef..f7def74d6b8 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx @@ -27,14 +27,6 @@ const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< typeof useRewardCampaigns >; -jest.mock('../hooks/useOndoOutcomeToast', () => ({ - useOndoOutcomeToast: jest.fn(), -})); -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; -const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< - typeof useOndoOutcomeToast ->; - jest.mock('../components/Campaigns/CampaignsGroup', () => { const ReactActual = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); @@ -173,7 +165,6 @@ describe('CampaignsView', () => { beforeEach(() => { jest.clearAllMocks(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); - mockUseOndoOutcomeToast.mockReturnValue(undefined); }); it('renders the header with the correct title', () => { @@ -374,11 +365,4 @@ describe('CampaignsView', () => { expect(queryByText('Refreshing...')).toBeNull(); }); }); - - describe('hook integration', () => { - it('calls useOndoOutcomeToast on render', () => { - render(); - expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/app/components/UI/Rewards/Views/CampaignsView.tsx b/app/components/UI/Rewards/Views/CampaignsView.tsx index b4b89d1ec1d..34786542250 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.tsx @@ -16,7 +16,6 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; import CampaignsGroup from '../components/Campaigns/CampaignsGroup'; @@ -31,7 +30,6 @@ import { strings } from '../../../../../locales/i18n'; const CampaignsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); - useOndoOutcomeToast(); const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = useRewardCampaigns(); diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx index b17b28efa50..520d10dfafd 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx @@ -14,7 +14,7 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import { OndoGmCampaignOutcomeBanner } from '../components/Campaigns/OndoCampaignOutcomeBanners'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import ErrorBoundary from '../../../Views/ErrorBoundary'; @@ -284,7 +284,7 @@ const OndoCampaignStatsView: React.FC = () => { {/* ── Outcome banner (campaign ended) ── */} {isCampaignComplete && participantOutcome && ( - ({ - __esModule: true, - default: 1, -})); - -const mockGoBack = jest.fn(); - -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), - useRoute: () => ({ - params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' }, - }), -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => { - const tw = (...args: unknown[]) => args; - tw.style = (...args: unknown[]) => args; - return { useTailwind: () => tw }; -}); - -jest.mock('react-native-safe-area-context', () => { - const actual = jest.requireActual('react-native-safe-area-context'); - return { - ...actual, - useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), - }; -}); - -jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({ - useOndoCampaignParticipantOutcome: jest.fn(), -})); - -const mockUseOndoCampaignParticipantOutcome = - useOndoCampaignParticipantOutcome as jest.MockedFunction< - typeof useOndoCampaignParticipantOutcome - >; +import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; +import CampaignWinningView from './CampaignWinningView'; -jest.mock('../../../Views/ErrorBoundary', () => { +jest.mock('./CampaignWinningView', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ children }: { children: React.ReactNode }) => - ReactActual.createElement(View, null, children), + default: jest.fn( + ({ + testID, + renderRankSection, + }: { + testID: string; + renderRankSection: () => React.ReactNode; + }) => ReactActual.createElement(View, { testID }, renderRankSection?.()), + ), }; }); -jest.mock('../hooks/useTrackRewardsPageView', () => ({ - __esModule: true, - default: jest.fn(), -})); - -jest.mock('../../../../core/Analytics', () => ({ - MetaMetricsEvents: { - REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED', - }, -})); - -jest.mock('../utils', () => ({ - RewardsMetricsButtons: { - COPY_REFERRAL_CODE: 'copy_referral_code', - }, -})); - -const mockTrackEvent = jest.fn(); -const mockBuild = jest.fn(() => ({})); -jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: () => ({ - addProperties: () => ({ build: mockBuild }), - }), - }), +jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({ + useOndoCampaignParticipantOutcome: jest.fn(), })); -const mockPosition = { - projectedTier: 'MID', - rank: 3, - totalInTier: 100, - rateOfReturn: 0.2823, - currentUsdValue: 2000, - totalUsdDeposited: 1000, - netDeposit: 900, - qualifiedDays: 10, - qualified: true, - neighbors: [], - computedAt: '2024-01-01T00:00:00.000Z', -}; - jest.mock('../hooks/useGetOndoLeaderboardPosition', () => ({ useGetOndoLeaderboardPosition: jest.fn(), })); -const mockUseGetOndoLeaderboardPosition = - useGetOndoLeaderboardPosition as jest.MockedFunction< - typeof useGetOndoLeaderboardPosition - >; +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' }, + }), +})); -jest.mock('../components/ReferralDetails/CopyableField', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - label, - value, - onCopy, - }: { - label: string; - value?: string | null; - onCopy?: () => void; - }) => - ReactActual.createElement( - View, - { testID: 'copyable-field' }, - ReactActual.createElement(Text, null, label), - ReactActual.createElement( - Text, - { testID: 'copyable-value' }, - value ?? '', - ), - ReactActual.createElement(Pressable, { - testID: 'copyable-trigger', - onPress: onCopy, - }), - ), - }; +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; }); -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn( - (key: string, params?: { place?: string; code?: string }) => { - const map: Record = { - 'rewards.ondo_campaign_winning.you_won': 'You won', - 'rewards.ondo_campaign_winning.email_instructions': - 'Email ondocampaign@consensys.net with your code to claim your prize.', - 'rewards.ondo_campaign_winning.open_mail': 'Open mail', - 'rewards.ondo_campaign_winning.skip_for_now': 'Skip for now', - 'rewards.ondo_campaign_winning.mail_subject': - 'Ondo campaign prize claim', - 'rewards.ondo_campaign_winning.mail_body': `My winning code: ${params?.code ?? ''}`, - 'rewards.ondo_campaign_winning.winning_code': 'Winning code', - 'rewards.ondo_campaign_winning.close_a11y': 'Close', - 'rewards.ondo_campaign_winning.error_title': - 'Could not load your winning code', - 'rewards.ondo_campaign_winning.error_description': - 'Something went wrong while fetching your code. Please try again later or contact support.', - 'rewards.ondo_campaign_winning.error_retry': 'Try again', - }; - if (key === 'rewards.ondo_campaign_winning.rank_label' && params?.place) { - return `${params.place} place`; - } - return map[key] ?? key; - }, - ), -})); +const mockUseOutcome = useOndoCampaignParticipantOutcome as jest.MockedFunction< + typeof useOndoCampaignParticipantOutcome +>; +const mockUsePosition = useGetOndoLeaderboardPosition as jest.MockedFunction< + typeof useGetOndoLeaderboardPosition +>; +const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< + typeof CampaignWinningView +>; describe('OndoCampaignWinningView', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: mockPosition, - isLoading: false, - hasError: false, - hasFetched: true, - refetch: jest.fn(), - }); - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ + mockUseOutcome.mockReturnValue({ outcome: { subscriptionId: 'sub-1', outcomeStatus: 'pending', - winnerVerificationCode: 'LVL346', + winnerVerificationCode: 'ONDO-WIN-99', }, isLoading: false, hasError: false, }); + mockUsePosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); }); - it('renders the main container', () => { + it('renders the container with the Ondo testID', () => { const { getByTestId } = render(); expect( getByTestId(ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER), ).toBeTruthy(); }); - it('shows you won, rank place, and rate from leaderboard position', () => { - const { getByText } = render(); - expect(getByText('You won')).toBeTruthy(); - expect(getByText('3rd place')).toBeTruthy(); - expect(getByText('+28.23%')).toBeTruthy(); - }); - - it('calls goBack when Skip for now is pressed', () => { - const { getByText } = render(); - fireEvent.press(getByText('Skip for now')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('calls goBack when close is pressed', () => { - const { getByLabelText } = render(); - fireEvent.press(getByLabelText('Close')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('copies referral code and tracks analytics when copy is triggered', () => { - const setStringSpy = jest.spyOn(Clipboard, 'setString'); - const { getByTestId } = render(); - fireEvent.press(getByTestId('copyable-trigger')); - expect(setStringSpy).toHaveBeenCalledWith('LVL346'); - expect(mockTrackEvent).toHaveBeenCalled(); - }); - - it('opens mailto when Open mail is pressed', async () => { - const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).toHaveBeenCalled(); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain('mailto:ondocampaign@consensys.net'); - expect(url).toContain(encodeURIComponent('LVL346')); - openSpy.mockRestore(); - }); - - describe('auto-redirect when user is not a winner', () => { - it('navigates to details view when outcome loaded but has no winner code', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, + it('passes correct Ondo-specific props to CampaignWinningView', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + testID: ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER, + prizeEmail: 'ondocampaign@consensys.net', + campaignName: 'Ondo Campaign', + campaignId: 'campaign-ondo-1', + analyticsPageType: 'ondo_campaign_winning', + winningCode: 'ONDO-WIN-99', + hasOutcomeLoaded: true, isLoading: false, - hasError: false, - }); - render(); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, - { campaignId: 'campaign-ondo-1' }, - ); - }); - - it('does not navigate while outcome is still loading', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: true, - hasError: false, - }); - render(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when outcome is null after load', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: false, - hasError: false, - }); - render(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); + }), + {}, + ); }); - describe('loading states', () => { - it('shows CopyableField once winning code has loaded', () => { - const { getByTestId } = render(); - expect(getByTestId('copyable-field')).toBeTruthy(); - }); - - it('shows the primary CTA in loading state while outcome is loading', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: true, - hasError: false, - }); - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).not.toHaveBeenCalled(); - openSpy.mockRestore(); - }); - - it('does not show the primary CTA in loading state once code has loaded', () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).toHaveBeenCalledTimes(1); - openSpy.mockRestore(); - }); - - it('hides rank and rate text while position is loading', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: true, - hasError: false, - hasFetched: false, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - }); - - describe('error states', () => { - it('hides the rank/rate section entirely when position fails to load', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: false, - hasError: true, - hasFetched: true, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - - it('hides the rank/rate section when position is null and not loading', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: false, - hasError: false, - hasFetched: true, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - }); - - it('does not throw when mailto openURL rejects', async () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockRejectedValue(new Error('no mail app')); - const { getByText } = render(); - await act(async () => { - fireEvent.press(getByText('Open mail')); + it('passes winningCode as null when outcome has no code', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + isLoading: false, + hasError: false, }); - expect(openSpy).toHaveBeenCalled(); - openSpy.mockRestore(); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: true, + }), + {}, + ); }); - describe('mailto URL construction', () => { - it('appends the winning code to the mail subject', async () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain( - encodeURIComponent('Ondo campaign prize claim - LVL346'), - ); - openSpy.mockRestore(); - }); - - it('uses base subject without code when winningCode is null', async () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, - isLoading: false, - hasError: false, - }); - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain(encodeURIComponent('Ondo campaign prize claim')); - expect(url).not.toContain(' - '); - openSpy.mockRestore(); + it('does not mark outcome as loaded until the outcome exists', () => { + mockUseOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: false, + }), + {}, + ); }); - it('does not copy to clipboard when winning code is null', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, + it('renderRankSection renders rank and rate when position is available', () => { + mockUsePosition.mockReturnValue({ + position: { rank: 3, rateOfReturn: 0.1234 } as never, isLoading: false, hasError: false, + hasFetched: true, + refetch: jest.fn(), }); - const setStringSpy = jest.spyOn(Clipboard, 'setString'); - const { getByTestId } = render(); - fireEvent.press(getByTestId('copyable-trigger')); - expect(setStringSpy).not.toHaveBeenCalled(); + + jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: { place?: string }) => { + if (key === 'rewards.campaign_winning.rank_label' && params?.place) + return `${params.place} place`; + return key; + }), + })); + + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + renderRankSection: expect.any(Function), + }), + {}, + ); }); }); diff --git a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx index 59b3434babe..33d8f6c45e9 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx @@ -1,45 +1,22 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Image, Linking, ScrollView, StyleSheet } from 'react-native'; -import Clipboard from '@react-native-clipboard/clipboard'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { - SafeAreaView, - useSafeAreaInsets, -} from 'react-native-safe-area-context'; +import React, { useMemo } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, BoxFlexDirection, - Button, - ButtonSize, - ButtonVariant, - ButtonIcon, - ButtonIconSize, - IconName, Skeleton, Text, TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import ErrorBoundary from '../../../Views/ErrorBoundary'; -import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; -import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; -import CopyableField from '../components/ReferralDetails/CopyableField'; import { formatOrdinalRank, formatPercentChange } from '../utils/formatUtils'; -import { RewardsMetricsButtons } from '../utils'; -import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; -import campaignWinningHero from '../../../../images/rewards/campaign_winning.png'; +import CampaignWinningView from './CampaignWinningView'; const PRIZE_EMAIL = 'ondocampaign@consensys.net'; -const styles = StyleSheet.create({ - heroBox: { aspectRatio: 1 }, -}); - // ParamListBase requires an index signature, which interfaces don't support // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type OndoCampaignWinningRouteParams = { @@ -52,14 +29,11 @@ export const ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS = { const OndoCampaignWinningView: React.FC = () => { const tw = useTailwind(); - const insets = useSafeAreaInsets(); - const navigation = useNavigation(); - const { trackEvent, createEventBuilder } = useAnalytics(); const route = useRoute< RouteProp >(); - const { campaignId } = route.params; + const { campaignId, campaignName = '' } = route.params; const { position, isLoading: positionLoading } = useGetOndoLeaderboardPosition(campaignId); @@ -68,53 +42,9 @@ const OndoCampaignWinningView: React.FC = () => { useOndoCampaignParticipantOutcome(campaignId); const winningCode = outcome?.winnerVerificationCode ?? null; - useEffect(() => { - if (!isOutcomeLoading && outcome && !winningCode) { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { - campaignId, - }); - } - }, [isOutcomeLoading, outcome, winningCode, campaignId, navigation]); - - useTrackRewardsPageView({ - page_type: 'ondo_campaign_winning', - campaign_id: campaignId, - }); - - const onDismiss = () => navigation.goBack(); - - const handleCopyWinningCode = useCallback(() => { - if (winningCode) { - Clipboard.setString(winningCode); - trackEvent( - createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED) - .addProperties({ - button_type: RewardsMetricsButtons.COPY_REFERRAL_CODE, - }) - .build(), - ); - } - }, [winningCode, trackEvent, createEventBuilder]); - - const handleOpenMail = useCallback(async () => { - const baseSubject = strings('rewards.ondo_campaign_winning.mail_subject'); - const subject = winningCode - ? `${baseSubject} - ${winningCode}` - : baseSubject; - const body = strings('rewards.ondo_campaign_winning.mail_body', { - code: winningCode || '—', - }); - const url = `mailto:${PRIZE_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; - try { - await Linking.openURL(url); - } catch { - // no-op: device may not have a mail handler - } - }, [winningCode]); - const rankDisplay = useMemo(() => { if (!position) return null; - return strings('rewards.ondo_campaign_winning.rank_label', { + return strings('rewards.campaign_winning.rank_label', { place: formatOrdinalRank(position.rank), }); }, [position]); @@ -124,130 +54,53 @@ const OndoCampaignWinningView: React.FC = () => { return formatPercentChange(position.rateOfReturn); }, [position]); - return ( - - { + if (!positionLoading && !position) return null; + return ( + - - - - - - - - - + ) : ( + + )} + + {rateDisplay !== null ? ( + - - {strings('rewards.ondo_campaign_winning.you_won')} - - - {(positionLoading || position) && ( - - {rankDisplay !== null ? ( - - {rankDisplay} - - ) : ( - - )} - - {rateDisplay !== null ? ( - - {rateDisplay} - - ) : ( - - )} - - )} + {rateDisplay} + + ) : ( + + )} + + ); + }; - - {strings('rewards.ondo_campaign_winning.email_instructions')} - - - - - - - - - - - - - - - + return ( + ); }; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx index 8e6213ed14d..e93d898040c 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ScrollView } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -35,6 +35,8 @@ import { formatUsd, } from '../utils/formatUtils'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type PerpsTradingCampaignStatsRouteParams = { @@ -113,6 +115,18 @@ const PerpsTradingCampaignStatsView: React.FC = () => { const positionError = hasError && !position; + const { outcome: participantOutcome } = + usePerpsTradingCampaignParticipantOutcome( + isCampaignComplete && isOptedIn ? campaignId : undefined, + ); + + const navigateToWinningView = useCallback(() => { + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, { + campaignId, + campaignName: campaign?.name ?? '', + }); + }, [navigation, campaignId, campaign]); + return ( { )} + {/* ── Outcome banner (campaign ended) ── */} + {isCampaignComplete && participantOutcome && ( + + )} + {/* ── Error banner ── */} {positionError && ( { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: jest.fn( + ({ + testID, + renderRankSection, + }: { + testID: string; + renderRankSection: () => React.ReactNode; + }) => ReactActual.createElement(View, { testID }, renderRankSection?.()), + ), + }; +}); + +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: 'campaign-perps-1', campaignName: 'Perps Campaign' }, + }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; +}); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: { place?: string }) => { + if (key === 'rewards.campaign_winning.rank_label' && params?.place) + return `${params.place} place`; + return key; + }), +})); + +const mockUseOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; +const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< + typeof CampaignWinningView +>; + +describe('PerpsTradingCampaignWinningView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WIN-99', + rank: 3, + }, + isLoading: false, + hasError: false, + }); + }); + + it('renders the container with the Perps testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER), + ).toBeTruthy(); + }); + + it('passes correct Perps-specific props to CampaignWinningView', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + testID: PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER, + prizeEmail: 'perpscampaign@consensys.net', + campaignName: 'Perps Campaign', + campaignId: 'campaign-perps-1', + analyticsPageType: 'perps_trading_campaign_winning', + winningCode: 'PERPS-WIN-99', + hasOutcomeLoaded: true, + isLoading: false, + }), + {}, + ); + }); + + it('passes winningCode as null when outcome has no code', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + rank: 21, + }, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: true, + }), + {}, + ); + }); + + it('does not mark outcome as loaded until the outcome exists', () => { + mockUseOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: false, + }), + {}, + ); + }); + + it('renderRankSection shows rank when available', () => { + render(); + const { renderRankSection } = mockCampaignWinningView.mock.calls[0][0]; + expect(renderRankSection).toBeDefined(); + const section = renderRankSection(); + expect(section).toBeTruthy(); + }); + + it('renderRankSection shows dash when outcome has no rank', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + rank: null, + }, + isLoading: false, + hasError: false, + }); + render(); + const { renderRankSection } = mockCampaignWinningView.mock.calls[0][0]; + // When rank is null, rankDisplay = '—' + expect(renderRankSection).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx new file mode 100644 index 00000000000..0eca7d70f8b --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx @@ -0,0 +1,87 @@ +import React, { useMemo } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import { strings } from '../../../../../locales/i18n'; +import { formatOrdinalRank } from '../utils/formatUtils'; +import CampaignWinningView from './CampaignWinningView'; + +const PRIZE_EMAIL = 'perpscampaign@consensys.net'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignWinningRouteParams = { + RewardsPerpsTradingCampaignWinning: { + campaignId: string; + campaignName: string; + }; +}; + +export const PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS = { + CONTAINER: 'perps-trading-campaign-winning-view-container', +} as const; + +const PerpsTradingCampaignWinningView: React.FC = () => { + const tw = useTailwind(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignWinningRouteParams, + 'RewardsPerpsTradingCampaignWinning' + > + >(); + const { campaignId, campaignName } = route.params; + + const { outcome, isLoading: isOutcomeLoading } = + usePerpsTradingCampaignParticipantOutcome(campaignId); + const winningCode = outcome?.winnerVerificationCode ?? null; + + const rankDisplay = useMemo(() => { + if (isOutcomeLoading && !outcome) { + return null; + } + if (!outcome?.rank) { + return '—'; + } + return strings('rewards.campaign_winning.rank_label', { + place: formatOrdinalRank(outcome.rank), + }); + }, [outcome, isOutcomeLoading]); + + const renderRankSection = () => { + if (rankDisplay === null) { + return ; + } + return ( + + {rankDisplay} + + ); + }; + + return ( + + ); +}; + +export default PerpsTradingCampaignWinningView; diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 98fb1ab3dfa..f4bd4c1685f 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -166,10 +166,6 @@ jest.mock('../hooks/useBulkLinkState', () => ({ useBulkLinkState: jest.fn(), })); -jest.mock('../hooks/useOndoOutcomeToast', () => ({ - useOndoOutcomeToast: jest.fn(), -})); - // Import mocked hooks import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals } from '../hooks/useRewardDashboardModals'; diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index ded32bc79da..e3e3812877c 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -21,7 +21,6 @@ import { RewardsDashboardModalType, } from '../hooks/useRewardDashboardModals'; import { useBulkLinkState } from '../hooks/useBulkLinkState'; -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; @@ -40,7 +39,6 @@ const RewardsDashboard: React.FC = () => { const hasTrackedDashboardViewed = useRef(false); useTrackRewardsPageView({ page_type: 'home' }); - useOndoOutcomeToast(); const hideUnlinkedAccountsBanner = useSelector( selectHideUnlinkedAccountsBanner, ); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx similarity index 67% rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx index 0b623fd0e53..56d7c49a5ba 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx @@ -5,8 +5,8 @@ import { WinnerFinalizedBanner, ParticipantFinalizedBanner, ParticipantPendingBanner, - OndoGmCampaignOutcomeBanner, -} from './OndoCampaignOutcomeBanners'; + CampaignOutcomeBanner, +} from './CampaignOutcomeBanners'; jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -28,13 +28,13 @@ jest.mock('../RewardsInfoBanner', () => { }); describe('WinnerPendingBanner', () => { - it('renders title and description', () => { + it('renders title and description using consolidated locale keys', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.winner_pending.title'), + getByText('rewards.campaign_outcome_banner.winner_pending.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.winner_pending.description'), + getByText('rewards.campaign_outcome_banner.winner_pending.description'), ).toBeDefined(); }); @@ -43,7 +43,7 @@ describe('WinnerPendingBanner', () => { , ); expect( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeDefined(); }); @@ -53,51 +53,53 @@ describe('WinnerPendingBanner', () => { , ); fireEvent.press( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ); expect(onPress).toHaveBeenCalledTimes(1); }); }); describe('WinnerFinalizedBanner', () => { - it('renders with winner_finalized strings', () => { + it('renders with consolidated winner_finalized strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.title'), + getByText('rewards.campaign_outcome_banner.winner_finalized.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.description'), + getByText('rewards.campaign_outcome_banner.winner_finalized.description'), ).toBeDefined(); }); }); describe('ParticipantFinalizedBanner', () => { - it('renders with participant_finalized strings', () => { + it('renders with consolidated participant_finalized strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.participant_finalized.title'), + getByText('rewards.campaign_outcome_banner.participant_finalized.title'), ).toBeDefined(); expect( getByText( - 'rewards.ondo_outcome_banner.participant_finalized.description', + 'rewards.campaign_outcome_banner.participant_finalized.description', ), ).toBeDefined(); }); }); describe('ParticipantPendingBanner', () => { - it('renders with participant_pending strings', () => { + it('renders with consolidated participant_pending strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.description'), + getByText( + 'rewards.campaign_outcome_banner.participant_pending.description', + ), ).toBeDefined(); }); }); -describe('OndoGmCampaignOutcomeBanner', () => { +describe('CampaignOutcomeBanner', () => { const onWinnerPress = jest.fn(); beforeEach(() => { @@ -106,83 +108,83 @@ describe('OndoGmCampaignOutcomeBanner', () => { it('renders WinnerPendingBanner when winner code is present and status is pending', () => { const { getByLabelText } = render( - , ); expect( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeDefined(); }); it('renders WinnerFinalizedBanner when winner code is present and status is finalized', () => { const { getByText, queryByLabelText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.title'), + getByText('rewards.campaign_outcome_banner.winner_finalized.title'), ).toBeDefined(); expect( - queryByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + queryByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeNull(); }); it('renders ParticipantFinalizedBanner when no code and status is finalized', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_finalized.title'), + getByText('rewards.campaign_outcome_banner.participant_finalized.title'), ).toBeDefined(); }); it('renders ParticipantPendingBanner when no code and status is pending', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); }); it('renders ParticipantPendingBanner when winnerVerificationCode is undefined', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); }); it('calls onWinnerPress when WinnerPendingBanner is pressed', () => { const mockOnWinnerPress = jest.fn(); const { getByLabelText } = render( - , ); fireEvent.press( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ); expect(mockOnWinnerPress).toHaveBeenCalledTimes(1); }); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx similarity index 53% rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx index 5127253bbb7..3402bca2d41 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx @@ -15,7 +15,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import RewardsInfoBanner from '../RewardsInfoBanner'; -import type { OndoGmCampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { CampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types'; export interface WinnerPendingBannerProps { onPress: () => void; @@ -25,7 +25,7 @@ export const WinnerPendingBanner = React.memo( ({ onPress }) => ( @@ -36,10 +36,12 @@ export const WinnerPendingBanner = React.memo( > - {strings('rewards.ondo_outcome_banner.winner_pending.title')} + {strings('rewards.campaign_outcome_banner.winner_pending.title')} - {strings('rewards.ondo_outcome_banner.winner_pending.description')} + {strings( + 'rewards.campaign_outcome_banner.winner_pending.description', + )} ( export const WinnerFinalizedBanner = React.memo(() => ( )); export const ParticipantFinalizedBanner = React.memo(() => ( )); export const ParticipantPendingBanner = React.memo(() => ( )); -export interface OndoGmCampaignOutcomeBannerProps { - outcomeStatus: OndoGmCampaignParticipantOutcomeStatus; +export interface CampaignOutcomeBannerProps { + outcomeStatus: CampaignParticipantOutcomeStatus; winnerVerificationCode: string | null | undefined; onWinnerPress: () => void; } -export const OndoGmCampaignOutcomeBanner = - React.memo( - ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => { - const hasCode = Boolean(winnerVerificationCode); - const isFinalized = outcomeStatus === 'finalized'; - if (hasCode && !isFinalized) - return ; - if (hasCode && isFinalized) return ; - if (isFinalized) return ; - return ; - }, - ); +export const CampaignOutcomeBanner = React.memo( + ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => { + const hasCode = Boolean(winnerVerificationCode); + const isFinalized = outcomeStatus === 'finalized'; + if (hasCode && !isFinalized) + return ; + if (hasCode && isFinalized) return ; + if (isFinalized) return ; + return ; + }, +); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx index 2ba425c2bf6..9926b195451 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx @@ -28,12 +28,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...args: unknown[]) => args }), })); -jest.mock('./OndoCampaignOutcomeBanners', () => { +jest.mock('./CampaignOutcomeBanners', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - OndoGmCampaignOutcomeBanner: ({ + CampaignOutcomeBanner: ({ outcomeStatus, winnerVerificationCode, }: { diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx index 0b9fa4b54cf..fd21c22ee46 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx @@ -24,7 +24,7 @@ import { formatPercentChange, formatUsd } from '../../utils/formatUtils'; import { ONDO_GM_REQUIRED_QUALIFIED_DAYS } from '../../utils/ondoCampaignConstants'; import { formatTierDisplayName } from './OndoLeaderboard.utils'; import RewardsErrorBanner from '../RewardsErrorBanner'; -import { OndoGmCampaignOutcomeBanner } from './OndoCampaignOutcomeBanners'; +import { CampaignOutcomeBanner } from './CampaignOutcomeBanners'; const CELL_STYLE = { flex: 1 } as const; @@ -244,7 +244,7 @@ const OndoCampaignStatsSummary: React.FC = ({ {/* Outcome banner (campaign ended) */} {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && ( - ({ + ToastContext: { Consumer: jest.fn(), Provider: jest.fn() }, +})); + +jest.mock('../../../../component-library/components/Toast/Toast.types', () => ({ + ToastVariants: { Icon: 'Icon', App: 'App', Plain: 'Plain' }, + ButtonIconVariant: { Icon: 'Icon' }, +})); + +jest.mock('../../../../component-library/components/Icons/Icon', () => ({ + IconName: { Close: 'Close', Confirmation: 'Confirmation', Star: 'Star' }, +})); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(), + useCallback: jest.fn((fn) => fn), + useMemo: jest.fn((fn) => fn()), +})); + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: jest.fn(), + useNavigation: jest.fn(), +})); + +jest.mock('../../../../util/haptics', () => ({ + playNotification: jest.fn(), + NotificationMoment: { + Success: 'Success', + Warning: 'Warning', + Error: 'Error', + }, +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + if (params?.campaignName) return `${key}:${params.campaignName}`; + return key; + }), +})); + +jest.mock('../../../../util/theme', () => { + const actual = jest.requireActual('../../../../util/theme'); + return { + ...actual, + useAppThemeFromContext: () => actual.mockTheme, + }; +}); + +jest.mock('../../../../reducers/rewards', () => ({ + dismissCampaignOutcomeToast: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaigns: jest.fn(), + selectDismissedCampaignOutcomeToasts: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../components/Campaigns/CampaignTile.utils', () => ({ + getCampaignStatus: jest.fn(() => 'complete'), +})); + +const mockDispatch = jest.fn(); +const mockNavigate = jest.fn(); +const mockShowToast = jest.fn(); +const mockCloseToast = jest.fn(); +const mockToastRef = { + current: { showToast: mockShowToast, closeToast: mockCloseToast }, +}; + +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< + typeof useFocusEffect +>; +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockDismissCampaignOutcomeToast = + dismissCampaignOutcomeToast as jest.MockedFunction< + typeof dismissCampaignOutcomeToast + >; + +const SUBSCRIPTION_ID = 'sub-123'; +const CAMPAIGN_ID = 'campaign-456'; +const CAMPAIGN_NAME = 'Test Campaign'; + +const mockUseOutcome = jest.fn(); + +const makeCompletedCampaign = (id = CAMPAIGN_ID, endDate = '2025-01-01') => ({ + id, + name: CAMPAIGN_NAME, + type: CampaignType.ONDO_HOLDING, + endDate, + startDate: '2024-01-01', +}); + +const WINNER_NAV = { + route: 'WinnerRoute', + params: { campaignId: CAMPAIGN_ID }, +}; +const NON_WINNER_NAV = { + route: 'NonWinnerRoute', + params: { campaignId: CAMPAIGN_ID }, +}; + +const mockConfig = { + campaignType: CampaignType.ONDO_HOLDING, + useOutcome: mockUseOutcome, + getWinnerNavigation: jest.fn(() => WINNER_NAV), + getNonWinnerNavigation: jest.fn(() => NON_WINNER_NAV), +}; + +function setupDefaults({ + campaigns = [makeCompletedCampaign()], + dismissed = {}, + subscriptionId = SUBSCRIPTION_ID, + outcome = null, +}: { + campaigns?: ReturnType[]; + dismissed?: Record; + subscriptionId?: string | null; + outcome?: BaseCampaignParticipantOutcomeDto | null; +} = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectCampaigns) return campaigns; + if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; + if (selector === selectRewardsSubscriptionId) return subscriptionId; + return undefined; + }); + mockUseOutcome.mockReturnValue({ + outcome, + isLoading: false, + hasError: false, + }); +} + +describe('useCampaignOutcomeToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); + (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef }); + mockUseFocusEffect.mockImplementation((cb) => { + cb(); + }); + mockDismissCampaignOutcomeToast.mockReturnValue({ + type: 'rewards/dismissCampaignOutcomeToast', + } as never); + mockConfig.getWinnerNavigation.mockReturnValue(WINNER_NAV); + mockConfig.getNonWinnerNavigation.mockReturnValue(NON_WINNER_NAV); + }); + + describe('does not show toast when', () => { + it('outcome is null', () => { + setupDefaults({ outcome: null }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('no campaigns match the campaignType', () => { + setupDefaults({ campaigns: [] }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('subscriptionId is missing', () => { + setupDefaults({ + subscriptionId: null, + outcome: { + subscriptionId: '', + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('winner toast was already dismissed', () => { + const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner`; + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + dismissed: { [key]: true }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('non_winner toast was already dismissed', () => { + const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:non_winner`; + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + dismissed: { [key]: true }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('outcome is finalized with a verification code (neither variant)', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); + + describe('winner toast', () => { + const winnerOutcome: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'WINNER-XYZ', + }; + + it('shows Plain variant toast with trophy startAccessory', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + startAccessory: expect.anything(), + }), + ); + }); + + it('uses consolidated winner locale keys', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + labelOptions: [ + { + label: 'rewards.campaign_outcome_toast.winner.title', + isBold: true, + }, + ], + descriptionOptions: { + description: `rewards.campaign_outcome_toast.winner.description:${CAMPAIGN_NAME}`, + }, + linkButtonOptions: expect.objectContaining({ + label: 'rewards.campaign_outcome_toast.winner.cta', + }), + }), + ); + }); + + it('shows close button with correct config', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + closeButtonOptions: expect.objectContaining({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + }), + }), + ); + }); + + it('fires success haptic via playNotification', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Success); + }); + }); + + describe('non-winner toast', () => { + const nonWinnerOutcome: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }; + + it('shows Icon variant toast with Confirmation icon', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + backgroundColor: 'transparent', + hasNoTimeout: true, + }), + ); + }); + + it('uses consolidated non_winner locale keys', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + labelOptions: [ + { + label: 'rewards.campaign_outcome_toast.non_winner.title', + isBold: true, + }, + ], + descriptionOptions: { + description: `rewards.campaign_outcome_toast.non_winner.description:${CAMPAIGN_NAME}`, + }, + linkButtonOptions: expect.objectContaining({ + label: 'rewards.campaign_outcome_toast.non_winner.cta', + }), + }), + ); + }); + + it('fires warning haptic via playNotification', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Warning); + }); + }); + + describe('handleDismiss', () => { + it('dispatches dismissCampaignOutcomeToast with variant winner and closes toast', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + closeButtonOptions.onPress(); + + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ + campaignId: CAMPAIGN_ID, + subscriptionId: SUBSCRIPTION_ID, + variant: 'winner', + }); + expect(mockCloseToast).toHaveBeenCalled(); + }); + + it('dispatches dismissCampaignOutcomeToast with variant non_winner', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + closeButtonOptions.onPress(); + + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ + campaignId: CAMPAIGN_ID, + subscriptionId: SUBSCRIPTION_ID, + variant: 'non_winner', + }); + }); + }); + + describe('handleCta', () => { + it('navigates to winner route and dismisses for winner variant', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions; + onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + WINNER_NAV.route as never, + WINNER_NAV.params as never, + ); + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'winner' }), + ); + }); + + it('navigates to non-winner route and dismisses for non_winner variant', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions; + onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + NON_WINNER_NAV.route as never, + NON_WINNER_NAV.params as never, + ); + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'non_winner' }), + ); + }); + }); + + describe('cleanup on blur', () => { + it('calls closeToast in cleanup when screen blurs', () => { + let cleanupFn: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((cb) => { + const cleanup = cb(); + if (typeof cleanup === 'function') cleanupFn = cleanup; + }); + + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + expect(cleanupFn).toBeDefined(); + cleanupFn?.(); + expect(mockCloseToast).toHaveBeenCalled(); + }); + + it('does not return cleanup when variant is null', () => { + let cleanupFn: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((cb) => { + const result = cb(); + if (typeof result === 'function') cleanupFn = result; + }); + + setupDefaults({ outcome: null }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + expect(cleanupFn).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('handles null toastRef gracefully', () => { + (useContext as jest.Mock).mockReturnValue({ toastRef: null }); + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + expect(() => + renderHook(() => useCampaignOutcomeToast(mockConfig)), + ).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts new file mode 100644 index 00000000000..4ea89f6df6e --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts @@ -0,0 +1,174 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { + useFocusEffect, + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { useDispatch, useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; +import { ToastContext } from '../../../../component-library/components/Toast'; +import type { + BaseCampaignParticipantOutcomeDto, + CampaignType, + CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; +import { + selectCampaigns, + selectDismissedCampaignOutcomeToasts, +} from '../../../../reducers/rewards/selectors'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import useRewardsToast from './useRewardsToast'; + +export interface CampaignOutcomeToastConfig { + campaignType: CampaignType; + useOutcome: (id: string | undefined) => { + outcome: BaseCampaignParticipantOutcomeDto | null; + }; + getWinnerNavigation: (campaign: CampaignDto) => { + route: string; + params: object; + }; + getNonWinnerNavigation: (campaign: CampaignDto) => { + route: string; + params: object; + }; +} + +export function useCampaignOutcomeToast( + config: CampaignOutcomeToastConfig, +): void { + const { + campaignType, + useOutcome, + getWinnerNavigation, + getNonWinnerNavigation, + } = config; + + const dispatch = useDispatch(); + const { toastRef } = useContext(ToastContext); + const { showToast, RewardsToastOptions } = useRewardsToast(); + const navigation = useNavigation>(); + + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const campaigns = useSelector(selectCampaigns); + const dismissed = useSelector(selectDismissedCampaignOutcomeToasts); + + const targetCampaign = useMemo(() => { + const completed = campaigns + .filter( + (c) => c.type === campaignType && getCampaignStatus(c) === 'complete', + ) + .sort( + (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ); + return completed[0] ?? null; + }, [campaigns, campaignType]); + + const { outcome } = useOutcome(targetCampaign?.id); + + // Standardized variant derivation: winner = has code and not yet finalized + const variant = useMemo((): 'winner' | 'non_winner' | null => { + if (!outcome) return null; + if ( + outcome.winnerVerificationCode && + outcome.outcomeStatus !== 'finalized' + ) { + return 'winner'; + } + if ( + outcome.outcomeStatus === 'finalized' && + !outcome.winnerVerificationCode + ) { + return 'non_winner'; + } + return null; + }, [outcome]); + + const isDismissed = useMemo(() => { + if (!variant || !targetCampaign || !subscriptionId) return true; + const key = `${targetCampaign.id}:${subscriptionId}:${variant}`; + return dismissed[key] === true; + }, [variant, targetCampaign, subscriptionId, dismissed]); + + const handleDismiss = useCallback(() => { + if (!variant || !targetCampaign || !subscriptionId) return; + dispatch( + dismissCampaignOutcomeToast({ + campaignId: targetCampaign.id, + subscriptionId, + variant, + }), + ); + toastRef?.current?.closeToast(); + }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]); + + const handleCta = useCallback(() => { + if (!targetCampaign || !variant) return; + handleDismiss(); + const nav = + variant === 'winner' + ? getWinnerNavigation(targetCampaign) + : getNonWinnerNavigation(targetCampaign); + navigation.navigate(nav.route, nav.params); + }, [ + variant, + targetCampaign, + handleDismiss, + navigation, + getWinnerNavigation, + getNonWinnerNavigation, + ]); + + useFocusEffect( + useCallback(() => { + if (!variant || isDismissed || !targetCampaign) return; + + const isWinner = variant === 'winner'; + if (isWinner) { + showToast( + RewardsToastOptions.outcomeWinner({ + title: strings('rewards.campaign_outcome_toast.winner.title'), + description: strings( + 'rewards.campaign_outcome_toast.winner.description', + { campaignName: targetCampaign.name ?? '' }, + ), + ctaLabel: strings('rewards.campaign_outcome_toast.winner.cta'), + onCtaPress: handleCta, + onClosePress: handleDismiss, + }), + ); + } else { + showToast( + RewardsToastOptions.outcomeNonWinner({ + title: strings('rewards.campaign_outcome_toast.non_winner.title'), + description: strings( + 'rewards.campaign_outcome_toast.non_winner.description', + { campaignName: targetCampaign.name ?? '' }, + ), + ctaLabel: strings('rewards.campaign_outcome_toast.non_winner.cta'), + onCtaPress: handleCta, + onClosePress: handleDismiss, + }), + ); + } + + return () => { + toastRef?.current?.closeToast(); + }; + }, [ + variant, + isDismissed, + targetCampaign, + toastRef, + showToast, + RewardsToastOptions, + handleCta, + handleDismiss, + ]), + ); +} + +export default useCampaignOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts new file mode 100644 index 00000000000..3f40088948e --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts @@ -0,0 +1,176 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; +import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantStatus: jest.fn(), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectCampaignParticipantStatus = + selectCampaignParticipantStatus as jest.MockedFunction< + typeof selectCampaignParticipantStatus + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MESSENGER_ACTION = 'RewardsController:getOndoCampaignParticipantOutcome'; +const CONFIG = { messengerAction: MESSENGER_ACTION }; + +const MOCK_OUTCOME: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'WINNER-XYZ', +}; + +function setupSelectors({ + subscriptionId = SUBSCRIPTION_ID, + isOptedIn = true, +}: { + subscriptionId?: string | null; + isOptedIn?: boolean; +} = {}) { + const participantStatusSelector = jest + .fn() + .mockReturnValue(isOptedIn ? { optedIn: true } : null); + mockSelectCampaignParticipantStatus.mockReturnValue( + participantStatusSelector, + ); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return subscriptionId; + if (selector === participantStatusSelector) + return isOptedIn ? { optedIn: true } : null; + return undefined; + }); +} + +describe('useCampaignParticipantOutcome', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns outcome when subscriptionId, campaignId, and isOptedIn are truthy', async () => { + setupSelectors(); + mockCall.mockResolvedValue(MOCK_OUTCOME); + + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(mockCall).toHaveBeenCalledWith( + MESSENGER_ACTION, + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(result.current.outcome).toEqual(MOCK_OUTCOME); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('returns null outcome when campaignId is undefined', async () => { + setupSelectors(); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(undefined, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('returns null outcome when subscriptionId is missing', async () => { + setupSelectors({ subscriptionId: null }); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('returns null outcome when user is not opted in', async () => { + setupSelectors({ isOptedIn: false }); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('sets hasError and clears outcome when the fetch throws', async () => { + setupSelectors(); + mockCall.mockRejectedValue(new Error('fetch failed')); + + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(true); + }); + + it('resets state when campaignId changes to undefined', async () => { + setupSelectors(); + mockCall.mockResolvedValue(MOCK_OUTCOME); + + const initialProps: { id: string | undefined } = { id: CAMPAIGN_ID }; + const { result, waitForNextUpdate, rerender } = renderHook( + ({ id }: { id: string | undefined }) => + useCampaignParticipantOutcome(id, CONFIG), + { initialProps }, + ); + + await act(async () => { + await waitForNextUpdate(); + }); + expect(result.current.outcome).toEqual(MOCK_OUTCOME); + + rerender({ id: undefined }); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts new file mode 100644 index 00000000000..b094725896e --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; +import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +export interface UseCampaignParticipantOutcomeResult< + T extends BaseCampaignParticipantOutcomeDto, +> { + outcome: T | null; + isLoading: boolean; + hasError: boolean; +} + +export interface CampaignOutcomeFetchConfig { + messengerAction: string; +} + +export function useCampaignParticipantOutcome< + T extends BaseCampaignParticipantOutcomeDto, +>( + campaignId: string | undefined, + config: CampaignOutcomeFetchConfig, +): UseCampaignParticipantOutcomeResult { + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = + useSelector(selectCampaignParticipantStatus(subscriptionId, campaignId)) + ?.optedIn === true; + const [outcome, setOutcome] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + + const fetchOutcome = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setOutcome(null); + setIsLoading(false); + setHasError(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + config.messengerAction as Parameters< + typeof Engine.controllerMessenger.call + >[0], + campaignId, + subscriptionId, + ); + setOutcome(result as T); + } catch { + setOutcome(null); + setHasError(true); + } finally { + setIsLoading(false); + } + }, [campaignId, subscriptionId, isOptedIn, config.messengerAction]); + + useEffect(() => { + fetchOutcome(); + }, [fetchOutcome]); + + return { outcome, isLoading, hasError }; +} + +export default useCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts index f41f2351164..b614232da40 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts @@ -89,6 +89,14 @@ describe('useLinkAccountAddress', () => { loading: jest.fn().mockReturnValue({ variant: 'loading', }), + outcomeWinner: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'success', + }), + outcomeNonWinner: jest.fn().mockReturnValue({ + variant: 'icon', + hapticsType: 'warning', + }), }; const mockAccount: InternalAccount = { diff --git a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts index 5e99c8b0f20..7c5c8603ec7 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts @@ -110,6 +110,14 @@ describe('useLinkAccountGroup', () => { loading: jest.fn().mockReturnValue({ variant: 'loading', }), + outcomeWinner: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'success', + }), + outcomeNonWinner: jest.fn().mockReturnValue({ + variant: 'icon', + hapticsType: 'warning', + }), }; // Mock account data diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts index 5dc86d8617a..6a4189dfcfb 100644 --- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts +++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts @@ -1,149 +1,65 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; +import { renderHook } from '@testing-library/react-hooks'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; -import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), +jest.mock('./useCampaignParticipantOutcome', () => ({ + useCampaignParticipantOutcome: jest.fn(), })); -jest.mock('../../../../core/Engine', () => ({ - controllerMessenger: { call: jest.fn() }, -})); - -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaignParticipantStatus: jest.fn(), -})); - -const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< - typeof Engine.controllerMessenger.call ->; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockSelectCampaignParticipantStatus = - selectCampaignParticipantStatus as jest.MockedFunction< - typeof selectCampaignParticipantStatus +const mockUseCampaignParticipantOutcome = + useCampaignParticipantOutcome as jest.MockedFunction< + typeof useCampaignParticipantOutcome >; const CAMPAIGN_ID = 'campaign-123'; -const SUBSCRIPTION_ID = 'sub-456'; - -const MOCK_OUTCOME: OndoGmCampaignParticipantOutcomeDto = { - subscriptionId: SUBSCRIPTION_ID, - outcomeStatus: 'pending', - winnerVerificationCode: 'WINNER-XYZ', -}; - -function setupSelectors({ - subscriptionId = SUBSCRIPTION_ID, - isOptedIn = true, -}: { - subscriptionId?: string | null; - isOptedIn?: boolean; -} = {}) { - const participantStatusSelector = jest - .fn() - .mockReturnValue(isOptedIn ? { optedIn: true } : null); - mockSelectCampaignParticipantStatus.mockReturnValue( - participantStatusSelector, - ); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectRewardsSubscriptionId) return subscriptionId; - if (selector === participantStatusSelector) - return isOptedIn ? { optedIn: true } : null; - return undefined; - }); -} describe('useOndoCampaignParticipantOutcome', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - it('returns null outcome and no loading when campaignId is undefined', async () => { - setupSelectors(); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(undefined), - ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); }); - it('returns null outcome when subscriptionId is missing', async () => { - setupSelectors({ subscriptionId: null }); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), - ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); - }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); - }); + it('delegates to useCampaignParticipantOutcome with the Ondo messenger action', () => { + renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID)); - it('returns null outcome when user is not opted in', async () => { - setupSelectors({ isOptedIn: false }); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith( + CAMPAIGN_ID, + { + messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome', + }, ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); - }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); }); - it('fetches outcome and returns it when all conditions are met', async () => { - setupSelectors(); - mockCall.mockResolvedValue(MOCK_OUTCOME); - - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), - ); + it('passes undefined campaignId through to the generic hook', () => { + renderHook(() => useOndoCampaignParticipantOutcome(undefined)); - await act(async () => { - await waitForNextUpdate(); + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, { + messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome', }); - - expect(mockCall).toHaveBeenCalledWith( - 'RewardsController:getOndoCampaignParticipantOutcome', - CAMPAIGN_ID, - SUBSCRIPTION_ID, - ); - expect(result.current.outcome).toEqual(MOCK_OUTCOME); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); }); - it('sets hasError and clears outcome when the fetch throws', async () => { - setupSelectors(); - mockCall.mockRejectedValue(new Error('fetch failed')); + it('returns the result from the generic hook', () => { + const mockOutcome = { + subscriptionId: 'sub-1', + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'CODE', + }; + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: mockOutcome, + isLoading: false, + hasError: false, + }); - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID), ); - await act(async () => { - await waitForNextUpdate(); - }); - - expect(result.current.outcome).toBeNull(); + expect(result.current.outcome).toEqual(mockOutcome); expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(true); + expect(result.current.hasError).toBe(false); }); }); diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts index eaabab7f48b..017c62bfc41 100644 --- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts +++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts @@ -1,58 +1,19 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { + useCampaignParticipantOutcome, + type UseCampaignParticipantOutcomeResult, +} from './useCampaignParticipantOutcome'; -export interface UseOndoCampaignParticipantOutcomeResult { - outcome: OndoGmCampaignParticipantOutcomeDto | null; - isLoading: boolean; - hasError: boolean; -} +export type UseOndoCampaignParticipantOutcomeResult = + UseCampaignParticipantOutcomeResult; export function useOndoCampaignParticipantOutcome( campaignId: string | undefined, ): UseOndoCampaignParticipantOutcomeResult { - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const isOptedIn = - useSelector(selectCampaignParticipantStatus(subscriptionId, campaignId)) - ?.optedIn === true; - const [outcome, setOutcome] = - useState(null); - const [isLoading, setIsLoading] = useState(false); - const [hasError, setHasError] = useState(false); - - const fetchOutcome = useCallback(async (): Promise => { - if (!subscriptionId || !campaignId || !isOptedIn) { - setOutcome(null); - setIsLoading(false); - setHasError(false); - return; - } - - try { - setIsLoading(true); - setHasError(false); - const result = await Engine.controllerMessenger.call( - 'RewardsController:getOndoCampaignParticipantOutcome', - campaignId, - subscriptionId, - ); - setOutcome(result); - } catch { - setOutcome(null); - setHasError(true); - } finally { - setIsLoading(false); - } - }, [campaignId, subscriptionId, isOptedIn]); - - useEffect(() => { - fetchOutcome(); - }, [fetchOutcome]); - - return { outcome, isLoading, hasError }; + return useCampaignParticipantOutcome( + campaignId, + { messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome' }, + ); } export default useOndoCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts index 7eff188438e..217d284cb64 100644 --- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts +++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts @@ -1,554 +1,98 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useContext } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { - playSuccessNotification, - playWarningNotification, -} from '../../../../util/haptics'; import { useOndoOutcomeToast } from './useOndoOutcomeToast'; -import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; -import { - selectCampaigns, - selectDismissedCampaignOutcomeToasts, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; import { CampaignType, - type OndoGmCampaignParticipantOutcomeDto, + type CampaignDto, } from '../../../../core/Engine/controllers/rewards-controller/types'; import Routes from '../../../../constants/navigation/Routes'; -import { - ToastVariants, - ButtonIconVariant, -} from '../../../../component-library/components/Toast/Toast.types'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; - -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: jest.fn(), - useCallback: jest.fn((fn) => fn), - useMemo: jest.fn((fn) => fn()), -})); - -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), - useSelector: jest.fn(), -})); - -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), - useNavigation: jest.fn(), -})); - -jest.mock('../../../../util/haptics', () => ({ - playSuccessNotification: jest.fn(), - playWarningNotification: jest.fn(), -})); - -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, params?: Record) => { - if (params?.campaignName) return `${key}:${params.campaignName}`; - return key; - }), -})); - -jest.mock('../../../../util/theme', () => { - const actual = jest.requireActual('../../../../util/theme'); - return { - ...actual, - useAppThemeFromContext: () => actual.mockTheme, - }; -}); - -jest.mock('../../../../reducers/rewards', () => ({ - dismissCampaignOutcomeToast: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaigns: jest.fn(), - selectDismissedCampaignOutcomeToasts: jest.fn(), -})); -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), +jest.mock('./useCampaignOutcomeToast', () => ({ + useCampaignOutcomeToast: jest.fn(), })); jest.mock('./useOndoCampaignParticipantOutcome', () => ({ useOndoCampaignParticipantOutcome: jest.fn(), })); -const mockDispatch = jest.fn(); -const mockNavigate = jest.fn(); -const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); -const mockToastRef = { - current: { showToast: mockShowToast, closeToast: mockCloseToast }, -}; - -const mockUseDispatch = useDispatch as jest.MockedFunction; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< - typeof useFocusEffect ->; -const mockUseNavigation = useNavigation as jest.MockedFunction< - typeof useNavigation ->; -const mockUseOndoCampaignParticipantOutcome = - useOndoCampaignParticipantOutcome as jest.MockedFunction< - typeof useOndoCampaignParticipantOutcome - >; -const mockDismissCampaignOutcomeToast = - dismissCampaignOutcomeToast as jest.MockedFunction< - typeof dismissCampaignOutcomeToast +const mockUseCampaignOutcomeToast = + useCampaignOutcomeToast as jest.MockedFunction< + typeof useCampaignOutcomeToast >; -const SUBSCRIPTION_ID = 'sub-123'; -const CAMPAIGN_ID = 'campaign-456'; -const CAMPAIGN_NAME = 'Ondo Test Campaign'; - -function makeParticipantOutcome( - options: Pick & { - winnerVerificationCode?: string | null; - subscriptionId?: string; - }, -): OndoGmCampaignParticipantOutcomeDto { - return { - subscriptionId: options.subscriptionId ?? SUBSCRIPTION_ID, - outcomeStatus: options.outcomeStatus, - winnerVerificationCode: options.winnerVerificationCode, - }; -} +const CAMPAIGN_ID = 'campaign-123'; +const CAMPAIGN_NAME = 'Ondo Campaign'; -const makeCompletedOndoCampaign = ( - id = CAMPAIGN_ID, - endDate = '2025-01-01T00:00:00Z', -) => ({ +const makeCampaign = (id = CAMPAIGN_ID): CampaignDto => ({ id, name: CAMPAIGN_NAME, type: CampaignType.ONDO_HOLDING, - endDate, - startDate: '2024-01-01T00:00:00Z', + endDate: '2025-01-01', + startDate: '2024-01-01', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: false, + showUpcomingDate: false, }); -function setupDefaults({ - campaigns = [], - dismissed = {}, - subscriptionId = SUBSCRIPTION_ID, - outcome = null, -}: { - campaigns?: ReturnType[]; - dismissed?: Record; - subscriptionId?: string | null; - outcome?: OndoGmCampaignParticipantOutcomeDto | null; -} = {}) { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectCampaigns) return campaigns; - if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; - if (selector === selectRewardsSubscriptionId) return subscriptionId; - return undefined; - }); - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome, - isLoading: false, - hasError: false, - }); -} - describe('useOndoOutcomeToast', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseDispatch.mockReturnValue(mockDispatch); - mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); - (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef }); - mockUseFocusEffect.mockImplementation((cb) => { - cb(); - }); - mockDismissCampaignOutcomeToast.mockReturnValue({ - type: 'rewards/dismissCampaignOutcomeToast', - } as never); - }); - - describe('targetCampaign selection', () => { - it('passes undefined to useOndoCampaignParticipantOutcome when no campaigns', () => { - setupDefaults({ campaigns: [] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - undefined, - ); - }); - - it('passes completed ONDO campaign id to useOndoCampaignParticipantOutcome', () => { - const campaign = makeCompletedOndoCampaign(); - setupDefaults({ campaigns: [campaign] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - CAMPAIGN_ID, - ); - }); - - it('selects the most recently ended campaign when multiple exist', () => { - const older = makeCompletedOndoCampaign( - 'campaign-old', - '2024-06-01T00:00:00Z', - ); - const newer = makeCompletedOndoCampaign( - 'campaign-new', - '2025-01-01T00:00:00Z', - ); - setupDefaults({ campaigns: [older, newer] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - 'campaign-new', - ); - }); - }); - - describe('variant derivation', () => { - it('does not show toast when outcome is null', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: null, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('derives winner_verify when outcome has verification code and is not finalized', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'WINNER-XYZ', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ iconName: IconName.Star }), - ); - }); - - it('derives participant_no_winner when outcome is finalized with no verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ iconName: IconName.Info }), - ); - }); - - it('does not show toast when outcome is finalized with a verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: 'WINNER-XYZ', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does not show toast when outcome is pending with no verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); }); - describe('dismissal check', () => { - it('does not show toast when winner_verify toast was previously dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner_verify`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does not show toast when participant_no_winner toast was previously dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('shows toast when a different variant was dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalled(); - }); - }); - - describe('toast configuration', () => { - it('shows winner_verify toast with correct config', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: ToastVariants.Icon, - iconName: IconName.Star, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: 'rewards.ondo_outcome_toast.winner_verify.title', - isBold: true, - }, - ], - descriptionOptions: { - description: `rewards.ondo_outcome_toast.winner_verify.description:${CAMPAIGN_NAME}`, - }, - linkButtonOptions: expect.objectContaining({ - label: 'rewards.ondo_outcome_toast.winner_verify.cta', - }), - closeButtonOptions: expect.objectContaining({ - variant: ButtonIconVariant.Icon, - iconName: IconName.Close, - }), - }), - ); - }); - - it('shows participant_no_winner toast with correct config', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: ToastVariants.Icon, - iconName: IconName.Info, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: 'rewards.ondo_outcome_toast.participant_no_winner.title', - isBold: true, - }, - ], - descriptionOptions: { - description: `rewards.ondo_outcome_toast.participant_no_winner.description:${CAMPAIGN_NAME}`, - }, - linkButtonOptions: expect.objectContaining({ - label: 'rewards.ondo_outcome_toast.participant_no_winner.cta', - }), - }), - ); - }); - - it('fires Success haptic for winner_verify', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(playSuccessNotification).toHaveBeenCalled(); - }); - - it('fires Warning haptic for participant_no_winner', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(playWarningNotification).toHaveBeenCalled(); - }); + it('calls useCampaignOutcomeToast with ONDO_HOLDING campaign type', () => { + renderHook(() => useOndoOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + campaignType: CampaignType.ONDO_HOLDING, + }), + ); }); - describe('cleanup on blur', () => { - it('calls closeToast in the cleanup function when screen blurs', () => { - let cleanupFn: (() => void) | undefined; - mockUseFocusEffect.mockImplementation((cb) => { - const cleanup = cb(); - if (typeof cleanup === 'function') cleanupFn = cleanup; - }); - - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(cleanupFn).toBeDefined(); - cleanupFn?.(); - expect(mockCloseToast).toHaveBeenCalledTimes(1); - }); - - it('does not return a cleanup function when variant is null', () => { - let cleanupFn: (() => void) | undefined; - mockUseFocusEffect.mockImplementation((cb) => { - const result = cb(); - if (typeof result === 'function') cleanupFn = result; - }); - - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: null, - }); - renderHook(() => useOndoOutcomeToast()); - - expect(cleanupFn).toBeUndefined(); - expect(mockCloseToast).not.toHaveBeenCalled(); - }); + it('passes useOndoCampaignParticipantOutcome as the useOutcome function', () => { + renderHook(() => useOndoOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + useOutcome: useOndoCampaignParticipantOutcome, + }), + ); }); - describe('handleDismiss', () => { - it('dispatches dismissCampaignOutcomeToast and closes toast when close button is pressed', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const closeButtonOptions = - mockShowToast.mock.calls[0][0].closeButtonOptions; - closeButtonOptions.onPress(); - - expect(mockDispatch).toHaveBeenCalledWith( - mockDismissCampaignOutcomeToast.mock.results[0]?.value, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'winner_verify', - }); - expect(mockCloseToast).toHaveBeenCalled(); + it('getWinnerNavigation returns ONDO winning view route with campaignId and campaignName', () => { + renderHook(() => useOndoOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, + params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME }, }); }); - describe('handleCta', () => { - it('navigates to winning view and dismisses for winner_verify', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const linkButtonOptions = - mockShowToast.mock.calls[0][0].linkButtonOptions; - linkButtonOptions.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, - { campaignId: CAMPAIGN_ID }, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'winner_verify', - }); + it('getWinnerNavigation uses empty string for campaignName when name is null', () => { + renderHook(() => useOndoOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation({ + ...makeCampaign(), + name: null as unknown as string, }); - - it('navigates to campaign details view and dismisses for participant_no_winner', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const linkButtonOptions = - mockShowToast.mock.calls[0][0].linkButtonOptions; - linkButtonOptions.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, - { campaignId: CAMPAIGN_ID }, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'participant_no_winner', - }); + expect(nav.params).toEqual({ + campaignId: CAMPAIGN_ID, + campaignName: '', }); }); - describe('edge cases', () => { - it('does not show toast when subscriptionId is missing', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - subscriptionId: null, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('handles null toastRef gracefully', () => { - (useContext as jest.Mock).mockReturnValue({ toastRef: null }); - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - expect(() => renderHook(() => useOndoOutcomeToast())).not.toThrow(); + it('getNonWinnerNavigation returns ONDO campaign details route', () => { + renderHook(() => useOndoOutcomeToast()); + const { getNonWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getNonWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: CAMPAIGN_ID }, }); }); }); diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts index 87c295cf1f7..2a736f931c8 100644 --- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts +++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts @@ -1,160 +1,21 @@ -import { useCallback, useContext, useMemo } from 'react'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { useDispatch, useSelector } from 'react-redux'; -import { - playSuccessNotification, - playWarningNotification, -} from '../../../../util/haptics'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import { - ButtonIconVariant, - ToastVariants, -} from '../../../../component-library/components/Toast/Toast.types'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; -import { useAppThemeFromContext } from '../../../../util/theme'; import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; -import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; -import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; -import { - selectCampaigns, - selectDismissedCampaignOutcomeToasts, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import Routes from '../../../../constants/navigation/Routes'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; -export type OutcomeToastVariant = 'winner_verify' | 'participant_no_winner'; - export function useOndoOutcomeToast(): void { - const dispatch = useDispatch(); - const { toastRef } = useContext(ToastContext); - const theme = useAppThemeFromContext(); - const navigation = useNavigation(); - - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const campaigns = useSelector(selectCampaigns); - const dismissed = useSelector(selectDismissedCampaignOutcomeToasts); - - const targetCampaign = useMemo(() => { - const completed = campaigns - .filter( - (c) => - c.type === CampaignType.ONDO_HOLDING && - getCampaignStatus(c) === 'complete', - ) - .sort( - (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), - ); - return completed[0] ?? null; - }, [campaigns]); - - const { outcome } = useOndoCampaignParticipantOutcome(targetCampaign?.id); - - const variant = useMemo((): OutcomeToastVariant | null => { - if (!outcome) return null; - if ( - outcome.winnerVerificationCode && - outcome.outcomeStatus !== 'finalized' - ) { - return 'winner_verify'; - } - if ( - outcome.outcomeStatus === 'finalized' && - !outcome.winnerVerificationCode - ) { - return 'participant_no_winner'; - } - return null; - }, [outcome]); - - const isDismissed = useMemo(() => { - if (!variant || !targetCampaign || !subscriptionId) return true; - const key = `${targetCampaign.id}:${subscriptionId}:${variant}`; - return dismissed[key] === true; - }, [variant, targetCampaign, subscriptionId, dismissed]); - - const handleDismiss = useCallback(() => { - if (!variant || !targetCampaign || !subscriptionId) return; - dispatch( - dismissCampaignOutcomeToast({ - campaignId: targetCampaign.id, - subscriptionId, - variant, - }), - ); - toastRef?.current?.closeToast(); - }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]); - - const handleCta = useCallback(() => { - if (!targetCampaign || !variant) return; - handleDismiss(); - if (variant === 'winner_verify') { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, { - campaignId: targetCampaign.id, - }); - } else { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { - campaignId: targetCampaign.id, - }); - } - }, [variant, targetCampaign, handleDismiss, navigation]); - - useFocusEffect( - useCallback(() => { - if (!variant || isDismissed || !targetCampaign) return; - - const isWinner = variant === 'winner_verify'; - toastRef?.current?.showToast({ - variant: ToastVariants.Icon, - iconName: isWinner ? IconName.Star : IconName.Info, - iconColor: isWinner - ? theme.colors.warning.default - : theme.colors.success.default, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: strings(`rewards.ondo_outcome_toast.${variant}.title`), - isBold: true, - }, - ], - descriptionOptions: { - description: strings( - `rewards.ondo_outcome_toast.${variant}.description`, - { campaignName: targetCampaign.name }, - ), - }, - linkButtonOptions: { - label: strings(`rewards.ondo_outcome_toast.${variant}.cta`), - onPress: handleCta, - }, - closeButtonOptions: { - variant: ButtonIconVariant.Icon, - iconName: IconName.Close, - onPress: handleDismiss, - }, - }); - if (isWinner) { - playSuccessNotification(); - } else { - playWarningNotification(); - } - - return () => { - toastRef?.current?.closeToast(); - }; - }, [ - variant, - isDismissed, - targetCampaign, - toastRef, - theme.colors.warning.default, - theme.colors.success.default, - handleCta, - handleDismiss, - ]), - ); + useCampaignOutcomeToast({ + campaignType: CampaignType.ONDO_HOLDING, + useOutcome: useOndoCampaignParticipantOutcome, + getWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, + params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, + }), + getNonWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: campaign.id }, + }), + }); } export default useOndoOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts new file mode 100644 index 00000000000..e2f2e1ba483 --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTradingCampaignEndedOutcomeToast } from './usePerpsTradingCampaignEndedOutcomeToast'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; +import { + CampaignType, + type CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; + +jest.mock('./useCampaignOutcomeToast', () => ({ + useCampaignOutcomeToast: jest.fn(), +})); + +jest.mock('./usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(), +})); + +const mockUseCampaignOutcomeToast = + useCampaignOutcomeToast as jest.MockedFunction< + typeof useCampaignOutcomeToast + >; + +const CAMPAIGN_ID = 'campaign-xyz'; +const CAMPAIGN_NAME = 'Perps Campaign'; + +const makeCampaign = (id = CAMPAIGN_ID, name = CAMPAIGN_NAME): CampaignDto => ({ + id, + name, + type: CampaignType.PERPS_TRADING, + endDate: '2025-01-01', + startDate: '2024-01-01', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: false, + showUpcomingDate: false, +}); + +describe('usePerpsTradingCampaignEndedOutcomeToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls useCampaignOutcomeToast with PERPS_TRADING campaign type', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + campaignType: CampaignType.PERPS_TRADING, + }), + ); + }); + + it('passes usePerpsTradingCampaignParticipantOutcome as the useOutcome function', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + useOutcome: usePerpsTradingCampaignParticipantOutcome, + }), + ); + }); + + it('getWinnerNavigation returns Perps winning view route with campaignId and campaignName', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME }, + }); + }); + + it('getWinnerNavigation uses empty string for campaignName when name is null', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation({ + ...makeCampaign(), + name: null as unknown as string, + }); + expect(nav.params).toEqual({ + campaignId: CAMPAIGN_ID, + campaignName: '', + }); + }); + + it('getNonWinnerNavigation returns campaigns view route', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getNonWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getNonWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_CAMPAIGNS_VIEW, + params: {}, + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts new file mode 100644 index 00000000000..93dbe8bc2be --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts @@ -0,0 +1,21 @@ +import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; + +export function usePerpsTradingCampaignEndedOutcomeToast(): void { + useCampaignOutcomeToast({ + campaignType: CampaignType.PERPS_TRADING, + useOutcome: usePerpsTradingCampaignParticipantOutcome, + getWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, + }), + getNonWinnerNavigation: () => ({ + route: Routes.REWARDS_CAMPAIGNS_VIEW, + params: {}, + }), + }); +} + +export default usePerpsTradingCampaignEndedOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts new file mode 100644 index 00000000000..35795726f9f --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; + +jest.mock('./useCampaignParticipantOutcome', () => ({ + useCampaignParticipantOutcome: jest.fn(), +})); + +const mockUseCampaignParticipantOutcome = + useCampaignParticipantOutcome as jest.MockedFunction< + typeof useCampaignParticipantOutcome + >; + +const CAMPAIGN_ID = 'campaign-xyz'; + +describe('usePerpsTradingCampaignParticipantOutcome', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + }); + + it('delegates to useCampaignParticipantOutcome with the Perps messenger action', () => { + renderHook(() => usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID)); + + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith( + CAMPAIGN_ID, + { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }, + ); + }); + + it('passes undefined campaignId through to the generic hook', () => { + renderHook(() => usePerpsTradingCampaignParticipantOutcome(undefined)); + + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }); + }); + + it('returns the result from the generic hook', () => { + const mockOutcome = { + subscriptionId: 'sub-1', + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'CODE', + rank: 3, + }; + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: mockOutcome, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID), + ); + + expect(result.current.outcome).toEqual(mockOutcome); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts new file mode 100644 index 00000000000..255885f2823 --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts @@ -0,0 +1,22 @@ +import type { PerpsTradingCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { + useCampaignParticipantOutcome, + type UseCampaignParticipantOutcomeResult, +} from './useCampaignParticipantOutcome'; + +export type UsePerpsTradingCampaignParticipantOutcomeResult = + UseCampaignParticipantOutcomeResult; + +export function usePerpsTradingCampaignParticipantOutcome( + campaignId: string | undefined, +): UsePerpsTradingCampaignParticipantOutcomeResult { + return useCampaignParticipantOutcome( + campaignId, + { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }, + ); +} + +export default usePerpsTradingCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx index 4f3ec45b6c7..aa29b8cbfb2 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx @@ -390,6 +390,68 @@ describe('useRewardsToast', () => { expect(mockCloseToast).toHaveBeenCalledTimes(1); }); + + it('returns outcomeWinner configuration with CTA and close handlers', () => { + const { result } = renderHook(() => useRewardsToast()); + const onCta = jest.fn(); + const onClose = jest.fn(); + const config = result.current.RewardsToastOptions.outcomeWinner({ + title: 'Winner title', + description: 'Winner body', + ctaLabel: 'Next', + onCtaPress: onCta, + onClosePress: onClose, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Success, + descriptionOptions: { description: 'Winner body' }, + linkButtonOptions: { label: 'Next' }, + }); + expect(config.labelOptions).toEqual([ + { label: 'Winner title', isBold: true }, + ]); + expect(config.startAccessory).toBeDefined(); + const { getByTestId } = render(config.startAccessory as ReactElement); + expect(getByTestId('rewards-nudge-start-accessory-box')).toBeDefined(); + config.linkButtonOptions?.onPress?.(); + config.closeButtonOptions?.onPress?.(); + expect(onCta).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('returns outcomeNonWinner configuration with CTA and close handlers', () => { + const { result } = renderHook(() => useRewardsToast()); + const onCta = jest.fn(); + const onClose = jest.fn(); + const config = result.current.RewardsToastOptions.outcomeNonWinner({ + title: 'Thanks title', + description: 'Thanks body', + ctaLabel: 'Done', + onCtaPress: onCta, + onClosePress: onClose, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: mockTheme.colors.success.default, + backgroundColor: 'transparent', + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + descriptionOptions: { description: 'Thanks body' }, + linkButtonOptions: { label: 'Done' }, + }); + expect(config.labelOptions).toEqual([ + { label: 'Thanks title', isBold: true }, + ]); + config.linkButtonOptions?.onPress?.(); + config.closeButtonOptions?.onPress?.(); + expect(onCta).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); describe('edge cases and error handling', () => { diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.tsx index db14b667bd8..48fa2bfe57c 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useContext, useMemo } from 'react'; import { ActivityIndicator } from 'react-native'; +import { Box } from '@metamask/design-system-react-native'; import { ToastContext } from '../../../../component-library/components/Toast'; import { ButtonIconVariant, @@ -18,12 +19,20 @@ import { } from '../../../../util/haptics'; import { strings } from '../../../../../locales/i18n'; import RewardsNotificationIcon from '../../../../images/rewards/notification.svg'; -import { Box } from '@metamask/design-system-react-native'; +import RewardsTrophyIcon from '../../../../images/rewards/trophy.svg'; export type RewardsToastOptions = ToastOptions & { hapticsType: HapticNotificationMoment; }; +export interface OutcomeCtaToastParams { + title: string; + description: string; + ctaLabel: string; + onCtaPress: () => void; + onClosePress: () => void; +} + export interface RewardsToastConfig { success: (title: string, subtitle?: string) => RewardsToastOptions; error: (title: string, subtitle?: string) => RewardsToastOptions; @@ -32,6 +41,8 @@ export interface RewardsToastConfig { enableNotificationsNudge: ( linkButtonOptions: ToastLinkButtonOptions, ) => RewardsToastOptions; + outcomeWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions; + outcomeNonWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions; } const getRewardsToastLabels = (title: string): ToastLabelOptions => { @@ -183,6 +194,64 @@ const useRewardsToast = (): { }, }, }), + outcomeWinner: ({ + title, + description, + ctaLabel, + onCtaPress, + onClosePress, + }: OutcomeCtaToastParams) => ({ + ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Success, + startAccessory: ( + + + + ), + labelOptions: getRewardsToastLabels(title), + descriptionOptions: { description }, + linkButtonOptions: { + label: ctaLabel, + onPress: onCtaPress, + }, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: onClosePress, + }, + }), + outcomeNonWinner: ({ + title, + description, + ctaLabel, + onCtaPress, + onClosePress, + }: OutcomeCtaToastParams) => ({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: theme.colors.success.default, + backgroundColor: 'transparent', + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + labelOptions: getRewardsToastLabels(title), + descriptionOptions: { description }, + linkButtonOptions: { + label: ctaLabel, + onPress: onCtaPress, + }, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: onClosePress, + }, + }), }), [ theme.colors.success.default, diff --git a/app/components/UI/Rewards/utils.ts b/app/components/UI/Rewards/utils.ts index cf902295145..fff1df35443 100644 --- a/app/components/UI/Rewards/utils.ts +++ b/app/components/UI/Rewards/utils.ts @@ -106,6 +106,7 @@ export enum RewardsMetricsButtons { VISIT_APP_STORE = 'visit_app_store', BUY_MUSD = 'buy_musd', SWAP_TO_MUSD = 'swap_to_musd', + COPY_WINNER_VERIFICATION_CODE = 'copy_winner_verification_code', } export const deriveAccountMetricProps = (account?: InternalAccount) => { diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 323016d1752..c3851369f74 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -111,6 +111,8 @@ const Routes = { REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW: 'RewardsSeasonOneCampaignDetails', REWARDS_CAMPAIGN_MECHANICS: 'RewardsCampaignMechanics', REWARDS_ONDO_CAMPAIGN_LEADERBOARD: 'RewardsOndoCampaignLeaderboard', + REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW: + 'RewardsPerpsTradingCampaignWinning', REWARDS_ONDO_CAMPAIGN_RWA_ASSET_SELECTOR: 'RewardsOndoRwaAssetSelector', REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW: 'RewardsOndoCampaignPortfolioView', REWARDS_ONDO_CAMPAIGN_STATS: 'RewardsOndoCampaignStats', diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index be1f9d95265..05948debe06 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -514,6 +514,19 @@ export type RewardsControllerGetOndoCampaignDepositsAction = { handler: RewardsController['getOndoCampaignDeposits']; }; +/** + * Fetch the participant outcome for the current user in a completed Perps Trading campaign. + * Results are cached for 10 minutes using a private in-memory Map. + * + * @param campaignId - The campaign ID. + * @param subscriptionId - The subscription ID for authentication. + * @returns The participant outcome DTO, or null if unavailable. + */ +export type RewardsControllerGetPerpsTradingCampaignParticipantOutcomeAction = { + type: `RewardsController:getPerpsTradingCampaignParticipantOutcome`; + handler: RewardsController['getPerpsTradingCampaignParticipantOutcome']; +}; + /** * Get the current user's position on the campaign leaderboard. * This is an authenticated endpoint. @@ -797,6 +810,7 @@ export type RewardsControllerMethodActions = | RewardsControllerGetCampaignParticipantStatusAction | RewardsControllerGetOndoCampaignLeaderboardAction | RewardsControllerGetOndoCampaignDepositsAction + | RewardsControllerGetPerpsTradingCampaignParticipantOutcomeAction | RewardsControllerGetOndoCampaignLeaderboardPositionAction | RewardsControllerGetOndoCampaignParticipantOutcomeAction | RewardsControllerGetOndoCampaignPortfolioPositionAction diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index ae1187bb7ad..f9b379bbe7a 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -20489,4 +20489,159 @@ describe('RewardsController', () => { ); }); }); + + describe('getPerpsTradingCampaignParticipantOutcome', () => { + let perpsParticipantOutcomeMessenger: jest.Mocked; + const mockCampaignId = 'perps-outcome-campaign-1'; + const mockSubscriptionId = 'sub-perps-outcome-1'; + const mockOutcome = { + subscriptionId: mockSubscriptionId, + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'VERIFY-123', + rank: 1, + }; + + beforeEach(() => { + perpsParticipantOutcomeMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + registerMethodActionHandlers: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('returns null when rewards feature flag is disabled', async () => { + const disabledController = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); + + const result = + await disabledController.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(perpsParticipantOutcomeMessenger.call).not.toHaveBeenCalled(); + }); + + it('fetches outcome from API and caches result', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(perpsParticipantOutcomeMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', + mockCampaignId, + mockSubscriptionId, + ); + expect(result).toEqual(mockOutcome); + }); + + it('returns cached outcome on second call within TTL', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + + await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + perpsParticipantOutcomeMessenger.call.mockClear(); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toEqual(mockOutcome); + expect(perpsParticipantOutcomeMessenger.call).not.toHaveBeenCalled(); + }); + + it('returns null when API returns null and does not cache', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(null); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + + perpsParticipantOutcomeMessenger.call.mockClear(); + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + const second = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + expect(perpsParticipantOutcomeMessenger.call).toHaveBeenCalledTimes(1); + expect(second).toEqual(mockOutcome); + }); + + it('returns null and logs on API error', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockRejectedValue( + new Error('Perps API error'), + ); + mockLogger.error.mockClear(); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'RewardsController: Failed to fetch Perps Trading participant outcome', + ); + }); + + it('logs when fetching fresh outcome', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + mockLogger.log.mockClear(); + + await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: Fetching Perps Trading campaign participant outcome', + ); + }); + }); }); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 33aa24818e1..65056cd9adb 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -33,6 +33,7 @@ import { type PerpsTradingCampaignLeaderboardPositionDto, type PerpsTradingCampaignVolumeDto, type PaginatedOndoGmActivityDto, + type PerpsTradingCampaignParticipantOutcomeDto, type OndoGmActivityState, type PointsEstimateHistoryEntry, ClaimRewardDto, @@ -154,6 +155,9 @@ const PERPS_TRADING_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS = // Perps Trading Campaign volume cache threshold const PERPS_TRADING_CAMPAIGN_VOLUME_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute +// Perps Trading participant outcome cache threshold +const PERPS_TRADING_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes + // Opt-in status stale threshold for not opted-in accounts to force a fresh check const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60; // 1 hour @@ -447,6 +451,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getPerpsTradingCampaignLeaderboardPosition', 'getPerpsTradingCampaignVolume', 'getOptInStatus', + 'getPerpsTradingCampaignParticipantOutcome', 'getPerpsDiscountForAccount', 'getPointsEvents', 'getPointsEventsIfChanged', @@ -503,6 +508,13 @@ export class RewardsController extends BaseController< #isBitcoinOptinEnabled: () => boolean; #isTronOptinEnabled: () => boolean; #reauthPromises: Map> = new Map(); + #perpsTradingParticipantOutcomeCache: Map< + string, + { + payload: PerpsTradingCampaignParticipantOutcomeDto | null; + lastFetched: number; + } + > = new Map(); /** * Calculate tier status and next tier information @@ -3695,6 +3707,58 @@ export class RewardsController extends BaseController< return result; } + /** + * Fetch the participant outcome for the current user in a completed Perps Trading campaign. + * Results are cached for 10 minutes using a private in-memory Map. + * @param campaignId - The campaign ID. + * @param subscriptionId - The subscription ID for authentication. + * @returns The participant outcome DTO, or null if unavailable. + */ + async getPerpsTradingCampaignParticipantOutcome( + campaignId: string, + subscriptionId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return null; + } + const key = `${subscriptionId}:${campaignId}`; + try { + return await wrapWithCache( + { + key, + ttl: PERPS_TRADING_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS, + readCache: (k) => + this.#perpsTradingParticipantOutcomeCache.get(k) ?? undefined, + fetchFresh: async () => + this.#withAuthRetry(async () => { + Logger.log( + 'RewardsController: Fetching Perps Trading campaign participant outcome', + ); + return this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', + campaignId, + subscriptionId, + ); + }, subscriptionId), + writeCache: (k, payload) => { + if (payload !== null) { + this.#perpsTradingParticipantOutcomeCache.set(k, { + payload, + lastFetched: Date.now(), + }); + } + }, + }, + ); + } catch (error) { + Logger.error( + error as Error, + 'RewardsController: Failed to fetch Perps Trading participant outcome', + ); + return null; + } + } + /** * Get the current user's position on the campaign leaderboard. * This is an authenticated endpoint. diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index a7c8b574492..03f5e90af95 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -5088,6 +5088,59 @@ describe('RewardsDataService', () => { }); }); + describe('getPerpsTradingCampaignParticipantOutcome', () => { + const mockCampaignId = 'perps-outcome-campaign-1'; + const mockSubscriptionId = 'sub-perps-outcome-1'; + const mockToken = 'test-bearer-token'; + const mockOutcome = { + subscriptionId: mockSubscriptionId, + outcomeStatus: 'finalized' as const, + winnerVerificationCode: null, + rank: 3, + }; + + beforeEach(() => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: mockToken, + }); + }); + + it('calls the authenticated perps outcome endpoint and returns data', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockOutcome), + } as unknown as Response); + + const result = await service.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/outcome/me`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'rewards-access-token': mockToken, + }), + }), + ); + expect(result).toEqual(mockOutcome); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401 } as Response); + + await expect( + service.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ), + ).rejects.toThrow('Get Perps Trading participant outcome failed: 401'); + }); + }); + describe('getPerpsTradingCampaignLeaderboard', () => { const mockCampaignId = 'perps-campaign-api-1'; const mockLeaderboard = { diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 1170e963d60..28b22224532 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -38,6 +38,7 @@ import type { PerpsTradingCampaignLeaderboardDto, PerpsTradingCampaignLeaderboardPositionDto, PerpsTradingCampaignVolumeDto, + PerpsTradingCampaignParticipantOutcomeDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import Logger from '../../../../../util/Logger'; @@ -280,6 +281,10 @@ export interface RewardsDataServiceGetPerpsTradingCampaignVolumeAction { type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignVolume`; handler: RewardsDataService['getPerpsTradingCampaignVolume']; } +export interface RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignParticipantOutcome`; + handler: RewardsDataService['getPerpsTradingCampaignParticipantOutcome']; +} export interface RewardsDataServiceGetRewardsEnvUrlAction { type: `${typeof SERVICE_NAME}:getRewardsEnvUrl`; @@ -355,7 +360,8 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction - | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction + | RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction; export type RewardsDataServiceMessenger = Messenger< typeof SERVICE_NAME, @@ -542,6 +548,10 @@ export class RewardsDataService { `${SERVICE_NAME}:getPerpsTradingCampaignVolume`, this.getPerpsTradingCampaignVolume.bind(this), ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignParticipantOutcome`, + this.getPerpsTradingCampaignParticipantOutcome.bind(this), + ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getRewardsEnvUrl`, this.getRewardsEnvUrl.bind(this), @@ -1827,4 +1837,22 @@ export class RewardsDataService { return (await response.json()) as PerpsTradingCampaignVolumeDto; } + + async getPerpsTradingCampaignParticipantOutcome( + campaignId: string, + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/outcome/me`, + { method: 'GET' }, + subscriptionId, + ); + if (!response.ok) { + throw new Error( + `Get Perps Trading participant outcome failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignParticipantOutcomeDto; + } } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 16cd347e36f..be6feb66841 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -93,8 +93,8 @@ export interface ApplyBonusCodeDto { */ export enum CampaignType { ONDO_HOLDING = 'ONDO_HOLDING', - SEASON_1 = 'SEASON_1', PERPS_TRADING = 'PERPS_TRADING', + SEASON_1 = 'SEASON_1', } /** @@ -567,16 +567,33 @@ export type OndoGmCampaignDepositsDto = { totalUsdDeposited: string; }; -export type OndoGmCampaignParticipantOutcomeStatus = 'pending' | 'finalized'; +export type CampaignParticipantOutcomeStatus = 'pending' | 'finalized'; -export interface OndoGmCampaignParticipantOutcomeDto { +export interface BaseCampaignParticipantOutcomeDto { subscriptionId: string; - outcomeStatus: OndoGmCampaignParticipantOutcomeStatus; + outcomeStatus: CampaignParticipantOutcomeStatus; winnerVerificationCode?: string | null; +} + +/** @deprecated Use CampaignParticipantOutcomeStatus */ +export type OndoGmCampaignParticipantOutcomeStatus = + CampaignParticipantOutcomeStatus; + +export interface OndoGmCampaignParticipantOutcomeDto + extends BaseCampaignParticipantOutcomeDto { tierRank?: number; tier?: string; } +/** @deprecated Use CampaignParticipantOutcomeStatus */ +export type PerpsTradingCampaignParticipantOutcomeStatus = + CampaignParticipantOutcomeStatus; + +export interface PerpsTradingCampaignParticipantOutcomeDto + extends BaseCampaignParticipantOutcomeDto { + rank?: number | null; +} + /** * Cached portfolio payload (explicit shape for Json / StateConstraint compatibility). */ diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index 043c69b146c..d6e48449142 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -71,6 +71,7 @@ import { RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction, RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction, RewardsDataServiceGetPerpsTradingCampaignVolumeAction, + RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction, } from '../../controllers/rewards-controller/services/rewards-data-service'; import { RootMessenger } from '../../types'; @@ -126,7 +127,8 @@ type AllowedActions = | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction - | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction + | RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction; // Don't reexport as per guidelines type AllowedEvents = @@ -217,6 +219,7 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getPerpsTradingCampaignLeaderboard', 'RewardsDataService:getPerpsTradingCampaignLeaderboardPosition', 'RewardsDataService:getPerpsTradingCampaignVolume', + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', ], events: [ 'AccountTreeController:selectedAccountGroupChange', diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 9ee33ec2753..59079e538a4 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -5857,35 +5857,33 @@ describe('ondoCampaignDeposits', () => { }); describe('dismissCampaignOutcomeToast', () => { - it('records winner_verify variant as dismissed', () => { + it('records winner variant as dismissed', () => { const state = rewardsReducer( initialState, dismissCampaignOutcomeToast({ - campaignId: 'campaign-1', - subscriptionId: 'sub-1', - variant: 'winner_verify', + campaignId: 'perps-c-1', + subscriptionId: 'sub-9', + variant: 'winner', }), ); expect( - state.dismissedCampaignOutcomeToasts['campaign-1:sub-1:winner_verify'], + state.dismissedCampaignOutcomeToasts['perps-c-1:sub-9:winner'], ).toBe(true); }); - it('records participant_no_winner variant as dismissed', () => { + it('records non_winner variant as dismissed', () => { const state = rewardsReducer( initialState, dismissCampaignOutcomeToast({ - campaignId: 'campaign-2', - subscriptionId: 'sub-2', - variant: 'participant_no_winner', + campaignId: 'ondo-c-1', + subscriptionId: 'sub-8', + variant: 'non_winner', }), ); expect( - state.dismissedCampaignOutcomeToasts[ - 'campaign-2:sub-2:participant_no_winner' - ], + state.dismissedCampaignOutcomeToasts['ondo-c-1:sub-8:non_winner'], ).toBe(true); }); @@ -5895,7 +5893,7 @@ describe('ondoCampaignDeposits', () => { dismissCampaignOutcomeToast({ campaignId: 'c1', subscriptionId: 's1', - variant: 'winner_verify', + variant: 'winner', }), ); state = rewardsReducer( @@ -5903,16 +5901,14 @@ describe('ondoCampaignDeposits', () => { dismissCampaignOutcomeToast({ campaignId: 'c2', subscriptionId: 's2', - variant: 'participant_no_winner', + variant: 'non_winner', }), ); - expect(state.dismissedCampaignOutcomeToasts['c1:s1:winner_verify']).toBe( + expect(state.dismissedCampaignOutcomeToasts['c1:s1:winner']).toBe(true); + expect(state.dismissedCampaignOutcomeToasts['c2:s2:non_winner']).toBe( true, ); - expect( - state.dismissedCampaignOutcomeToasts['c2:s2:participant_no_winner'], - ).toBe(true); }); it('starts with empty dismissedCampaignOutcomeToasts in initial state', () => { @@ -5925,7 +5921,7 @@ describe('ondoCampaignDeposits', () => { const persisted: RewardsState = { ...initialState, dismissedCampaignOutcomeToasts: { - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }, }; @@ -5935,7 +5931,7 @@ describe('ondoCampaignDeposits', () => { }); expect(state.dismissedCampaignOutcomeToasts).toEqual({ - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }); }); @@ -5958,7 +5954,7 @@ describe('ondoCampaignDeposits', () => { ...initialState, candidateSubscriptionId: 'old-sub', dismissedCampaignOutcomeToasts: { - 'campaign-1:old-sub:winner_verify': true, + 'campaign-1:old-sub:winner': true, }, }; @@ -5968,7 +5964,7 @@ describe('ondoCampaignDeposits', () => { ); expect(state.dismissedCampaignOutcomeToasts).toEqual({ - 'campaign-1:old-sub:winner_verify': true, + 'campaign-1:old-sub:winner': true, }); }); }); diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index b733c5c10d8..e35e437aaa3 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -866,7 +866,7 @@ const rewardsSlice = createSlice({ action: PayloadAction<{ campaignId: string; subscriptionId: string; - variant: 'winner_verify' | 'participant_no_winner'; + variant: 'winner' | 'non_winner'; }>, ) => { const key = `${action.payload.campaignId}:${action.payload.subscriptionId}:${action.payload.variant}`; diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index 9bac9897bab..d703e8d15f6 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -3829,8 +3829,8 @@ describe('Rewards selectors', () => { it('returns the dismissed toasts map', () => { const dismissed = { - 'campaign-1:sub-1:winner_verify': true, - 'campaign-2:sub-1:participant_no_winner': true, + 'campaign-1:sub-1:winner': true, + 'campaign-2:sub-1:non_winner': true, }; const state = createMockRootState({ dismissedCampaignOutcomeToasts: dismissed, @@ -3841,11 +3841,11 @@ describe('Rewards selectors', () => { it('returns true for a dismissed toast key', () => { const state = createMockRootState({ dismissedCampaignOutcomeToasts: { - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }, }); const result = selectDismissedCampaignOutcomeToasts(state); - expect(result['campaign-1:sub-1:winner_verify']).toBe(true); + expect(result['campaign-1:sub-1:winner']).toBe(true); }); it('returns undefined for a key that has not been dismissed', () => { @@ -3853,7 +3853,7 @@ describe('Rewards selectors', () => { dismissedCampaignOutcomeToasts: {}, }); const result = selectDismissedCampaignOutcomeToasts(state); - expect(result['campaign-1:sub-1:winner_verify']).toBeUndefined(); + expect(result['campaign-1:sub-1:winner']).toBeUndefined(); }); }); }); diff --git a/locales/languages/de.json b/locales/languages/de.json index 46be6a42eed..28b98585ba4 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -8658,29 +8658,18 @@ "retry_button": "Erneut versuchen", "refreshing": "Aktualisierung ..." }, - "ondo_campaign_winning": { - "you_won": "Sie haben gewonnen", - "rank_label": "{{place}} Ort", - "email_instructions": "Senden Sie eine E-Mail an ondocampaign@consensys.net mit Ihrem Code, um Ihren Preis zu erhalten.", - "open_mail": "E-Mail öffnen", - "skip_for_now": "Vorläufig überspringen", - "mail_subject": "Ondo-Kampagnenpreis einfordern", - "mail_body": "Mein Gewinncode: {{code}}", - "winning_code": "Gewinncode", - "close_a11y": "Schließen" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Sie haben gewonnen", - "description": "Fordern Sie Ihren Preis noch heute ein.", - "a11y": "Gewinnerdetails öffnen" + "title": "Sie haben gewonnen!", + "description": "Überprüfen Sie Ihren Gewinncode und fordern Sie Ihren Preis noch heute ein.", + "a11y": "Details anzeigen" }, "participant_pending": { "title": "Kampagne ist beendet.", "description": "Wir ermitteln gerade die Ergebnisse. Schauen Sie bald wieder vorbei." }, "participant_finalized": { - "title": "Kampagnenergebnisse sind verfügbar", + "title": "Die Kampagnenergebnisse liegen vor.", "description": "Sie haben dieses Mal nicht gewonnen. Überprüfen Sie die Rangliste, um Ihre Platzierung einzusehen." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ihre Belohnung ist unterwegs – wir werden uns in Kürze bei Ihnen melden." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Sie haben gewonnen 💰", - "description": "Fordern Sie Ihren Preis aus der {{campaignName}} ein", + "description": "Fordern Sie Ihren Preis aus der {{campaignName}} noch heute ein.", "cta": "Details anzeigen" }, - "participant_no_winner": { - "title": "Die Ergebnisse sind verfügbar. 🏅", - "description": "Sehen Sie Ihre Platzierung in der {{campaignName}} ein", + "non_winner": { + "title": "Die Ergebnisse liegen vor 🏅", + "description": "Sehen Sie Ihre Platzierung in der {{campaignName}}.", "cta": "Ansehen" } + }, + "campaign_winning": { + "you_won": "Sie haben gewonnen", + "rank_label": "{{place}} Ort", + "email_instructions": "Senden Sie eine E-Mail an {{email}} mit Ihrem Code, um Ihren Preis zu erhalten.", + "open_mail": "E-Mail öffnen", + "skip_for_now": "Vorläufig überspringen", + "mail_subject": "{{campaignName}} – Preis einfordern", + "mail_body": "Mein Gewinncode: {{code}}", + "winning_code": "Gewinncode", + "close_a11y": "Schließen" } }, "time": { diff --git a/locales/languages/el.json b/locales/languages/el.json index 8df088c6d71..13ec23005cd 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -8658,29 +8658,18 @@ "retry_button": "Επανάληψη", "refreshing": "Ανανεώνεται..." }, - "ondo_campaign_winning": { - "you_won": "Κερδίσατε", - "rank_label": "θέση {{place}}", - "email_instructions": "Στείλτε email στο ondocampaign@consensys.net με τον κωδικό σας για να διεκδικήσετε το βραβείο σας.", - "open_mail": "Άνοιγμα αλληλογραφίας", - "skip_for_now": "Παράλειψη για τώρα", - "mail_subject": "Διεκδίκηση βραβείου στην καμπάνια του Ondo", - "mail_body": "Κωδικός νίκης: {{code}}", - "winning_code": "Κωδικός νίκης", - "close_a11y": "Κλείσιμο" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Κερδίσατε", - "description": "Διεκδικήστε το βραβείο σας σήμερα.", - "a11y": "Άνοιγμα λεπτομερειών νικητή" + "title": "Κερδίσατε!", + "description": "Επαληθεύστε τον κωδικό νίκης σας και διεκδικήστε το βραβείο σας σήμερα.", + "a11y": "Προβολή λεπτομερειών" }, "participant_pending": { "title": "Η καμπάνια έχει λήξει.", "description": "Επεξεργαζόμαστε τα αποτελέσματα. Ελέγξτε ξανά σύντομα." }, "participant_finalized": { - "title": "Τα αποτελέσματα της καμπάνιας είναι έτοιμα", + "title": "Τα αποτελέσματα της καμπάνιας είναι έτοιμα.", "description": "Δεν κερδίσατε αυτή τη φορά. Δείτε τον πίνακα κατάταξης για να δείτε σε ποια θέση τερματίσατε." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Το βραβείο σας είναι καθ’ οδόν — θα επικοινωνήσουμε μαζί σας σύντομα." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Κερδίσατε 💰", - "description": "Διεκδικήστε το βραβείο σας από την {{campaignName}}", + "description": "Διεκδικήστε το βραβείο σας από την {{campaignName}} σήμερα.", "cta": "Δείτε λεπτομέρειες" }, - "participant_no_winner": { - "title": "Τα αποτελέσματα είναι έτοιμα. 🏅", - "description": "Δείτε την κατάταξή σας στην {{campaignName}}", + "non_winner": { + "title": "Τα αποτελέσματα είναι έτοιμα 🏅", + "description": "Δείτε την κατάταξή σας στην {{campaignName}}.", "cta": "Προβολή" } + }, + "campaign_winning": { + "you_won": "Κερδίσατε", + "rank_label": "θέση {{place}}", + "email_instructions": "Στείλτε email στο {{email}} με τον κωδικό σας για να διεκδικήσετε το βραβείο σας.", + "open_mail": "Άνοιγμα αλληλογραφίας", + "skip_for_now": "Παράλειψη για τώρα", + "mail_subject": "{{campaignName}} — διεκδίκηση βραβείου", + "mail_body": "Κωδικός νίκης: {{code}}", + "winning_code": "Κωδικός νίκης", + "close_a11y": "Κλείσιμο" } }, "time": { diff --git a/locales/languages/en.json b/locales/languages/en.json index aeb7ba9d85e..f1f456c5309 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -8667,29 +8667,29 @@ "retry_button": "Retry", "refreshing": "Refreshing..." }, - "ondo_campaign_winning": { + "campaign_winning": { "you_won": "You won", "rank_label": "{{place}} place", - "email_instructions": "Email ondocampaign@consensys.net with your code to claim your prize.", + "email_instructions": "Email {{email}} with your code to claim your prize.", "open_mail": "Open mail", "skip_for_now": "Skip for now", - "mail_subject": "Ondo campaign prize claim", + "mail_subject": "{{campaignName}} prize claim", "mail_body": "My winning code: {{code}}", "winning_code": "Winning code", "close_a11y": "Close" }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "You won", - "description": "Claim your prize today.", - "a11y": "Open winner details" + "title": "You won!", + "description": "Verify your winning code and claim your prize today.", + "a11y": "View details" }, "participant_pending": { "title": "Campaign has ended.", "description": "We're determining the results. Check back soon." }, "participant_finalized": { - "title": "Campaign results are in", + "title": "Campaign results are in.", "description": "You didn't win this time. Check the leaderboard to see where you finished." }, "winner_finalized": { @@ -8697,15 +8697,15 @@ "description": "Your reward is on its way — we'll be in touch shortly." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "You won 💰", - "description": "Claim your prize from the {{campaignName}}", + "description": "Claim your prize from the {{campaignName}} today.", "cta": "View details" }, - "participant_no_winner": { - "title": "The results are in. 🏅", - "description": "See your ranking in the {{campaignName}}", + "non_winner": { + "title": "The results are in 🏅", + "description": "See your ranking in the {{campaignName}}.", "cta": "View" } } diff --git a/locales/languages/es.json b/locales/languages/es.json index f228b7929ee..7a1d9ed8af8 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -8658,29 +8658,18 @@ "retry_button": "Reintentar", "refreshing": "Actualizando..." }, - "ondo_campaign_winning": { - "you_won": "Tú ganas", - "rank_label": "{{place}} lugar", - "email_instructions": "Para reclamar tu premio, envía un correo electrónico a ondocampaign@consensys.net con tu código.", - "open_mail": "Abrir correo", - "skip_for_now": "Omitir por ahora", - "mail_subject": "Reclamación de premio de la campaña de Ondo", - "mail_body": "Mi código ganador: {{code}}", - "winning_code": "Código ganador", - "close_a11y": "Cerrar" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Tú ganas", - "description": "Reclama tu premio hoy mismo.", - "a11y": "Abrir detalles del ganador" + "title": "¡Has ganado!", + "description": "Verifica tu código ganador y reclama tu premio hoy.", + "a11y": "Ver detalles" }, "participant_pending": { "title": "La campaña terminó.", "description": "Estamos determinando los resultados. Vuelve a consultar pronto." }, "participant_finalized": { - "title": "Los resultados de la campaña están en", + "title": "Ya hay resultados de la campaña.", "description": "Esta vez no has ganado. Consulta la clasificación para ver en qué posición has quedado." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Tu recompensa está en camino; nos pondremos en contacto contigo en breve." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Tú ganas 💰", - "description": "Reclama tu premio en la {{campaignName}}", + "description": "Reclama tu premio de la {{campaignName}} hoy.", "cta": "Ver detalles" }, - "participant_no_winner": { - "title": "Ya tenemos los resultados. 🏅", - "description": "Consulta tu clasificación en {{campaignName}}", + "non_winner": { + "title": "Ya hay resultados 🏅", + "description": "Consulta tu posición en la {{campaignName}}.", "cta": "Ver" } + }, + "campaign_winning": { + "you_won": "Tú ganas", + "rank_label": "{{place}} lugar", + "email_instructions": "Para reclamar tu premio, envía un correo electrónico a {{email}} con tu código.", + "open_mail": "Abrir correo", + "skip_for_now": "Omitir por ahora", + "mail_subject": "{{campaignName}} – reclamación de premio", + "mail_body": "Mi código ganador: {{code}}", + "winning_code": "Código ganador", + "close_a11y": "Cerrar" } }, "time": { diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 5d699bb6f69..22621832f2d 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -8658,29 +8658,18 @@ "retry_button": "Réessayer", "refreshing": "Actualisation en cours…" }, - "ondo_campaign_winning": { - "you_won": "Vous avez gagné", - "rank_label": "{{place}} place", - "email_instructions": "Envoyez votre code par e-mail à ondocampaign@consensys.net pour réclamer votre récompense.", - "open_mail": "Ouvrir l’e-mail", - "skip_for_now": "Ignorer pour l’instant", - "mail_subject": "Réclamation du prix que vous avez gagné en participant à la campagne Ondo", - "mail_body": "Mon code gagnant : {{code}}", - "winning_code": "Code gagnant", - "close_a11y": "Fermer" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Vous avez gagné", - "description": "Réclamez votre prix dès aujourd’hui.", - "a11y": "Afficher les détails du gagnant" + "title": "Vous avez gagné !", + "description": "Vérifiez votre code gagnant et réclamez votre récompense aujourd’hui.", + "a11y": "Voir les détails" }, "participant_pending": { "title": "La campagne est terminée.", "description": "Nous sommes en train d’évaluer les résultats. Revenez bientôt." }, "participant_finalized": { - "title": "Les résultats de la campagne sont disponibles", + "title": "Les résultats de la campagne sont disponibles.", "description": "Vous n’avez pas gagné cette fois-ci. Découvrez quelle place vous occupez au classement final." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Votre prix est en route. Nous vous contacterons bientôt." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Vous avez gagné 💰", - "description": "Réclamez le prix que vous avez gagné en participant à la campagne {{campaignName}}", + "description": "Réclamez votre récompense de la {{campaignName}} aujourd’hui.", "cta": "Voir les détails" }, - "participant_no_winner": { - "title": "Les résultats sont disponibles. 🏅", - "description": "Découvrez votre classement dans la campagne {{campaignName}}", + "non_winner": { + "title": "Les résultats sont disponibles 🏅", + "description": "Consultez votre classement dans la {{campaignName}}.", "cta": "Afficher" } + }, + "campaign_winning": { + "you_won": "Vous avez gagné", + "rank_label": "{{place}} place", + "email_instructions": "Envoyez votre code par e-mail à {{email}} pour réclamer votre récompense.", + "open_mail": "Ouvrir l’e-mail", + "skip_for_now": "Ignorer pour l’instant", + "mail_subject": "{{campaignName}} – réclamation de prix", + "mail_body": "Mon code gagnant : {{code}}", + "winning_code": "Code gagnant", + "close_a11y": "Fermer" } }, "time": { diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 962fd14ccc8..ced9d00e771 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -8658,29 +8658,18 @@ "retry_button": "फिर से प्रयास करें", "refreshing": "रिफ्रेश हो रहा है..." }, - "ondo_campaign_winning": { - "you_won": "आप जीत गए", - "rank_label": "{{place}} जगह", - "email_instructions": "अपना इनाम पाने के लिए ondocampaign@consensys.net पर अपना कोड ईमेल करें।", - "open_mail": "मेल खोलें", - "skip_for_now": "अभी के लिए स्किप करें", - "mail_subject": "Ondo कैंपेन प्राइज़ क्लेम", - "mail_body": "मेरा विनिंग कोड: {{code}}", - "winning_code": "विनिंग कोड", - "close_a11y": "बंद करें" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "आप जीत गए", - "description": "आज ही अपना प्राइज़ क्लेम करें।", - "a11y": "विनर की जानकारी खोलें" + "title": "आप जीत गए!", + "description": "अपना विजेता कोड सत्यापित करें और आज ही अपना इनाम दावा करें।", + "a11y": "विवरण देखें" }, "participant_pending": { "title": "कैंपेन खत्म हो गया है।", "description": "हम रिज़ल्ट तय कर रहे हैं। जल्द ही वापस आकर देखें।" }, "participant_finalized": { - "title": "कैंपेन के रिज़ल्ट आ गए हैं", + "title": "कैंपेन के परिणाम आ गए हैं।", "description": "आप इस बार नहीं जीते। लीडरबोर्ड देखें कि आप कहाँ पर हैं।" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "आपका रिवॉर्ड आने वाला है — हम जल्द ही आपसे कॉन्टैक्ट करेंगे।" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "आप जीत गए 💰", - "description": "{{campaignName}} से अपना प्राइज़ क्लेम करें", + "description": "आज ही {{campaignName}} से अपना इनाम दावा करें।", "cta": "विवरण देखें" }, - "participant_no_winner": { - "title": "रिज़ल्ट आ गए हैं। 🏅", - "description": "{{campaignName}} में अपनी रैंकिंग देखें", + "non_winner": { + "title": "परिणाम आ गए हैं 🏅", + "description": "{{campaignName}} में अपनी रैंकिंग देखें।", "cta": "देखें" } + }, + "campaign_winning": { + "you_won": "आप जीत गए", + "rank_label": "{{place}} जगह", + "email_instructions": "अपना इनाम पाने के लिए {{email}} पर अपना कोड ईमेल करें।", + "open_mail": "मेल खोलें", + "skip_for_now": "अभी के लिए स्किप करें", + "mail_subject": "{{campaignName}} – पुरस्कार दावा", + "mail_body": "मेरा विनिंग कोड: {{code}}", + "winning_code": "विनिंग कोड", + "close_a11y": "बंद करें" } }, "time": { diff --git a/locales/languages/id.json b/locales/languages/id.json index 487c8399e66..b9384ae959a 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -8658,29 +8658,18 @@ "retry_button": "Coba lagi", "refreshing": "Menyegarkan..." }, - "ondo_campaign_winning": { - "you_won": "Anda menang", - "rank_label": "{{place}} tempat", - "email_instructions": "Kirimkan kode Anda ke ondocampaign@consensys.net untuk mengklaim hadiah Anda.", - "open_mail": "Buka surat", - "skip_for_now": "Lewati untuk saat ini", - "mail_subject": "Klaim hadiah kampanye Ondo", - "mail_body": "Kode pemenang: {{code}}", - "winning_code": "Kode pemenang", - "close_a11y": "Tutup" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Anda menang", - "description": "Klaim hadiah Anda hari ini.", - "a11y": "Buka detail pemenang" + "title": "Anda menang!", + "description": "Verifikasi kode kemenangan Anda dan klaim hadiah Anda hari ini.", + "a11y": "Lihat detail" }, "participant_pending": { "title": "Kampanye telah berakhir.", "description": "Kami sedang menentukan hasilnya. Periksa kembali sesaat lagi." }, "participant_finalized": { - "title": "Hasil kampanye telah keluar", + "title": "Hasil kampanye sudah tersedia.", "description": "Anda tidak menang kali ini. Periksa papan peringkat untuk melihat posisi Anda." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Reward sedang dikirim — kami akan segera menghubungi Anda." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Anda menang 💰", - "description": "Klaim hadiah dari {{campaignName}}", + "description": "Klaim hadiah Anda dari {{campaignName}} hari ini.", "cta": "Lihat detail" }, - "participant_no_winner": { - "title": "Hasilnya sudah keluar. 🏅", - "description": "Lihat peringkat Anda di {{campaignName}}", + "non_winner": { + "title": "Hasil sudah tersedia 🏅", + "description": "Lihat peringkat Anda di {{campaignName}}.", "cta": "Lihat" } + }, + "campaign_winning": { + "you_won": "Anda menang", + "rank_label": "{{place}} tempat", + "email_instructions": "Kirimkan kode Anda ke {{email}} untuk mengklaim hadiah Anda.", + "open_mail": "Buka surat", + "skip_for_now": "Lewati untuk saat ini", + "mail_subject": "Klaim hadiah {{campaignName}}", + "mail_body": "Kode pemenang: {{code}}", + "winning_code": "Kode pemenang", + "close_a11y": "Tutup" } }, "time": { diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 9950f780af3..62c3a10836a 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -8658,29 +8658,18 @@ "retry_button": "再試行", "refreshing": "更新中..." }, - "ondo_campaign_winning": { - "you_won": "予測的中", - "rank_label": "{{place}}位", - "email_instructions": "賞品を請求するには、ondocampaign@consensys.netにメールでコードを送信してください。", - "open_mail": "メールを開く", - "skip_for_now": "今はスキップ", - "mail_subject": "Ondoキャンペーン賞品の請求", - "mail_body": "勝利コード: {{code}}", - "winning_code": "勝利コード", - "close_a11y": "閉じる" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "予測的中", - "description": "今すぐ賞品を請求しましょう。", - "a11y": "勝者の詳細を開く" + "title": "当選しました!", + "description": "当選コードを確認し、本日中に賞品を請求してください。", + "a11y": "詳細を表示" }, "participant_pending": { "title": "キャンペーンが終了しました。", "description": "結果を判断しています。近々またご確認ください。" }, "participant_finalized": { - "title": "キャンペーンの結果が出ました", + "title": "キャンペーンの結果が出ました。", "description": "今回は勝ちませんでした。リーダーボードで順位を確認しましょう。" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "間もなく報酬を受け取れます。近々ご連絡します。" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "予測的中 💰", - "description": "{{campaignName}}の賞品を請求", + "description": "本日、{{campaignName}}の賞品を請求してください。", "cta": "詳細を表示" }, - "participant_no_winner": { - "title": "結果が出ました。🏅", - "description": "{{campaignName}}でのランキングをご覧ください", + "non_winner": { + "title": "結果が出ました 🏅", + "description": "{{campaignName}}での順位を確認してください。", "cta": "表示" } + }, + "campaign_winning": { + "you_won": "予測的中", + "rank_label": "{{place}}位", + "email_instructions": "賞品を請求するには、{{email}}にメールでコードを送信してください。", + "open_mail": "メールを開く", + "skip_for_now": "今はスキップ", + "mail_subject": "{{campaignName}} 賞品の請求", + "mail_body": "勝利コード: {{code}}", + "winning_code": "勝利コード", + "close_a11y": "閉じる" } }, "time": { diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 7406260871f..01372f65546 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -8658,29 +8658,18 @@ "retry_button": "다시 시도", "refreshing": "새로 고침 중..." }, - "ondo_campaign_winning": { - "you_won": "승리하셨습니다", - "rank_label": "{{place}} 장소", - "email_instructions": "상품을 받으려면 당첨 코드를 ondocampaign@consensys.net으로 이메일로 보내세요.", - "open_mail": "메일 열기", - "skip_for_now": "지금은 건너뛰기", - "mail_subject": "Ondo 캠페인 상품 청구", - "mail_body": "당첨 코드: {{code}}", - "winning_code": "당첨 코드", - "close_a11y": "닫기" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "승리하셨습니다", - "description": "오늘 상품을 청구하세요.", - "a11y": "당첨자 세부 정보 열기" + "title": "당첨되셨습니다!", + "description": "당첨 코드를 확인하고 오늘 상품을 청구하세요.", + "a11y": "세부정보 보기" }, "participant_pending": { "title": "캠페인이 종료되었습니다.", "description": "결과를 확인 중입니다. 곧 다시 확인해 주세요." }, "participant_finalized": { - "title": "캠페인 결과가 발표되었습니다", + "title": "캠페인 결과가 나왔습니다.", "description": "이번에는 당첨되지 않았습니다. 리더보드에서 순위를 확인해 보세요." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "보상이 지급될 예정입니다. 곧 연락드리겠습니다." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "승리하셨습니다 💰", - "description": "{{campaignName}} 상품을 청구하세요", + "description": "오늘 {{campaignName}}에서 상품을 청구하세요.", "cta": "세부 정보 보기" }, - "participant_no_winner": { - "title": "결과가 발표되었습니다. 🏅", - "description": "{{campaignName}}에서 내 순위 보기", + "non_winner": { + "title": "결과가 나왔습니다 🏅", + "description": "{{campaignName}}에서 내 순위를 확인하세요.", "cta": "보기" } + }, + "campaign_winning": { + "you_won": "승리하셨습니다", + "rank_label": "{{place}} 장소", + "email_instructions": "상품을 받으려면 당첨 코드를 {{email}}으로 이메일로 보내세요.", + "open_mail": "메일 열기", + "skip_for_now": "지금은 건너뛰기", + "mail_subject": "{{campaignName}} 상품 청구", + "mail_body": "당첨 코드: {{code}}", + "winning_code": "당첨 코드", + "close_a11y": "닫기" } }, "time": { diff --git a/locales/languages/pt.json b/locales/languages/pt.json index bc8a465f6fa..9e606f5776b 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -8658,29 +8658,18 @@ "retry_button": "Tentar novamente", "refreshing": "Atualizando..." }, - "ondo_campaign_winning": { - "you_won": "Você ganhou", - "rank_label": "{{place}} lugar", - "email_instructions": "Envie um e-mail para ondocampaign@consensys.net com o seu código para reivindicar seu prêmio.", - "open_mail": "Abrir e-mail", - "skip_for_now": "Ignorar por enquanto", - "mail_subject": "Reivindicação de prêmio da campanha Ondo", - "mail_body": "Meu código ganhador: {{code}}", - "winning_code": "Código ganhador", - "close_a11y": "Fechar" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Você ganhou", - "description": "Reivindique seu prêmio hoje mesmo.", - "a11y": "Ver detalhes dos ganhadores" + "title": "Você ganhou!", + "description": "Verifique seu código vencedor e reivindique seu prêmio hoje.", + "a11y": "Ver detalhes" }, "participant_pending": { "title": "A campanha terminou.", "description": "Estamos analisando os resultados. Volte em breve." }, "participant_finalized": { - "title": "Os resultados da campanha foram divulgados", + "title": "Os resultados da campanha estão disponíveis.", "description": "Você não ganhou desta vez. Confira o placar de classificação para ver sua posição final." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Sua recompensa está a caminho. Entraremos em contato em breve." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Você ganhou 💰", - "description": "Reivindique seu prêmio da {{campaignName}}", + "description": "Reivindique seu prêmio da {{campaignName}} hoje.", "cta": "Ver detalhes" }, - "participant_no_winner": { - "title": "Os resultados foram divulgados.🏅", - "description": "Veja sua classificação na {{campaignName}}", + "non_winner": { + "title": "Os resultados estão disponíveis 🏅", + "description": "Veja sua classificação na {{campaignName}}.", "cta": "Ver" } + }, + "campaign_winning": { + "you_won": "Você ganhou", + "rank_label": "{{place}} lugar", + "email_instructions": "Envie um e-mail para {{email}} com o seu código para reivindicar seu prêmio.", + "open_mail": "Abrir e-mail", + "skip_for_now": "Ignorar por enquanto", + "mail_subject": "{{campaignName}} – reivindicação de prêmio", + "mail_body": "Meu código ganhador: {{code}}", + "winning_code": "Código ganhador", + "close_a11y": "Fechar" } }, "time": { diff --git a/locales/languages/ru.json b/locales/languages/ru.json index ef6b6f31ae2..f292c81431e 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -8658,29 +8658,18 @@ "retry_button": "Повтор", "refreshing": "Обновление..." }, - "ondo_campaign_winning": { - "you_won": "Вы выиграли", - "rank_label": "{{place}} место", - "email_instructions": "Отправьте письмо со своим кодом на адрес ondocampaign@consensys.net, чтобы получить приз.", - "open_mail": "Открыть почту", - "skip_for_now": "Пока пропустить", - "mail_subject": "Получение приза кампании Ondo", - "mail_body": "Мой выигрышный код: {{code}}", - "winning_code": "Выигрышный код", - "close_a11y": "Закрыть" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Вы выиграли", - "description": "Получите свой приз сегодня.", - "a11y": "Открыть детали победителя" + "title": "Вы выиграли!", + "description": "Подтвердите выигрышный код и получите приз сегодня.", + "a11y": "Подробнее" }, "participant_pending": { "title": "Кампания завершена.", "description": "Мы определяем результаты. Загляните сюда позже." }, "participant_finalized": { - "title": "Результаты кампании подведены", + "title": "Результаты кампании готовы.", "description": "В этот раз вы не выиграли. Проверьте таблицу лидеров, чтобы узнать свое место." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ваша награда уже в пути — мы скоро свяжемся с вами." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Вы выиграли 💰", - "description": "Получите свой приз в кампании {{campaignName}}", + "description": "Получите приз из {{campaignName}} сегодня.", "cta": "Просмотр подробностей" }, - "participant_no_winner": { - "title": "Результаты готовы. 🏅", - "description": "Посмотрите свой рейтинг в кампании {{campaignName}}", + "non_winner": { + "title": "Результаты готовы 🏅", + "description": "Посмотрите своё место в {{campaignName}}.", "cta": "Просмотр" } + }, + "campaign_winning": { + "you_won": "Вы выиграли", + "rank_label": "{{place}} место", + "email_instructions": "Отправьте письмо со своим кодом на адрес {{email}}, чтобы получить приз.", + "open_mail": "Открыть почту", + "skip_for_now": "Пока пропустить", + "mail_subject": "{{campaignName}} – получение приза", + "mail_body": "Мой выигрышный код: {{code}}", + "winning_code": "Выигрышный код", + "close_a11y": "Закрыть" } }, "time": { diff --git a/locales/languages/tl.json b/locales/languages/tl.json index a1cc9eb084e..c5b4425bcbd 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -8658,29 +8658,18 @@ "retry_button": "Subukang muli", "refreshing": "Nire-refresh..." }, - "ondo_campaign_winning": { - "you_won": "Nanalo ka", - "rank_label": "{{place}} lugar", - "email_instructions": "I-email sa ondocampaign@consensys.net ang code mo para kunin ang premyo mo.", - "open_mail": "Buksan ang mail", - "skip_for_now": "Laktawan muna", - "mail_subject": "Pagkuha ng premyo ng campaign ng Ondo", - "mail_body": "Ang nanalong code: {{code}}", - "winning_code": "Nanalong code", - "close_a11y": "Isara" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Nanalo ka", - "description": "Kunin ang premyo mo ngayon.", - "a11y": "Ipakita ang mga detalye ng nanalo" + "title": "Nanalo ka!", + "description": "I-verify ang winning code mo at kunin ang premyo mo ngayon.", + "a11y": "Tingnan ang mga detalye" }, "participant_pending": { "title": "Tapos na ang campaign.", "description": "Inaalam na namin ang resulta. Bumalik sa lalong madaling panahon." }, "participant_finalized": { - "title": "Lumabas na ang resulta ng campaign", + "title": "Lumabas na ang resulta ng campaign.", "description": "Hindi ka nanalo sa pagkakataong ito. Tingnan ang leaderboard para malaman kung ano ang rank mo." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Parating na ang reward mo — makikipag-ugnayan kami sa iyo sa lalong madaling panahon." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Nanalo ka 💰", - "description": "Kunin ang premyo mo mula sa {{campaignName}}", + "description": "Kunin ang premyo mo mula sa {{campaignName}} ngayon.", "cta": "Tingnan ang mga detalye" }, - "participant_no_winner": { - "title": "Lumabas na ang resulta. 🏅", - "description": "Tingnan ang ranking mo sa {{campaignName}}", + "non_winner": { + "title": "Lumabas na ang resulta 🏅", + "description": "Tingnan ang ranking mo sa {{campaignName}}.", "cta": "Tingnan" } + }, + "campaign_winning": { + "you_won": "Nanalo ka", + "rank_label": "{{place}} lugar", + "email_instructions": "I-email sa {{email}} ang code mo para kunin ang premyo mo.", + "open_mail": "Buksan ang mail", + "skip_for_now": "Laktawan muna", + "mail_subject": "{{campaignName}} – pagkuha ng premyo", + "mail_body": "Ang nanalong code: {{code}}", + "winning_code": "Nanalong code", + "close_a11y": "Isara" } }, "time": { diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 3fc8b5216ac..2a71fe184a4 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -8658,29 +8658,18 @@ "retry_button": "Tekrar Dene", "refreshing": "Yenileniyor..." }, - "ondo_campaign_winning": { - "you_won": "Kazancınız", - "rank_label": "{{place}}. sıra", - "email_instructions": "Ödülünüzü almak için kodunuzla ondocampaign@consensys.net adresine e-posta gönderin.", - "open_mail": "Postayı aç", - "skip_for_now": "Şimdilik atla", - "mail_subject": "Ondo kampanyası ödül talebi", - "mail_body": "Kazanma kodum: {{code}}", - "winning_code": "Kazanma kodu", - "close_a11y": "Kapat" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Kazancınız", - "description": "Ödülünüzü bugün alın.", - "a11y": "Kazanan bilgilerini aç" + "title": "Kazandınız!", + "description": "Kazanan kodunuzu doğrulayın ve ödülünüzü bugün talep edin.", + "a11y": "Ayrıntıları görüntüle" }, "participant_pending": { "title": "Kampanya sona erdi.", "description": "Sonuçları belirliyoruz. Kısa bir süre tekrar kontrol edin." }, "participant_finalized": { - "title": "Kampanya sonuçları açıklandı", + "title": "Kampanya sonuçları hazır.", "description": "Bu kez kazanamadınız. Nerede bitirdiğinizi görmek için liderlik tablosunu kontrol edin." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ödülünüz yolda - kısa bir süre sonra iletişime geçeceğiz." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Kazancınız 💰", - "description": "{{campaignName}} adlı kampanyadan ödülünüzü alın", + "description": "Ödülünüzü bugün {{campaignName}} üzerinden talep edin.", "cta": "Ayrıntıları görüntüle" }, - "participant_no_winner": { - "title": "Sonuçlar belli oldu. 🏅", - "description": "{{campaignName}} adlı kampanyadaki sıralamanızı görün", + "non_winner": { + "title": "Sonuçlar hazır 🏅", + "description": "{{campaignName}} içinde sıralamanızı görün.", "cta": "Görüntüle" } + }, + "campaign_winning": { + "you_won": "Kazancınız", + "rank_label": "{{place}}. sıra", + "email_instructions": "Ödülünüzü almak için kodunuzla {{email}} adresine e-posta gönderin.", + "open_mail": "Postayı aç", + "skip_for_now": "Şimdilik atla", + "mail_subject": "{{campaignName}} – ödül talebi", + "mail_body": "Kazanma kodum: {{code}}", + "winning_code": "Kazanma kodu", + "close_a11y": "Kapat" } }, "time": { diff --git a/locales/languages/vi.json b/locales/languages/vi.json index a3e14e0a16b..6fc67e4c02d 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -8658,29 +8658,18 @@ "retry_button": "Thử lại", "refreshing": "Đang làm mới..." }, - "ondo_campaign_winning": { - "you_won": "Bạn đã thắng", - "rank_label": "Hạng {{place}}", - "email_instructions": "Gửi email đến ondocampaign@consensys.net kèm mã của bạn để nhận thưởng.", - "open_mail": "Mở thư", - "skip_for_now": "Bỏ qua bây giờ", - "mail_subject": "Nhận thưởng chiến dịch Ondo", - "mail_body": "Mã trúng thưởng của tôi: {{code}}", - "winning_code": "Mã trúng thưởng", - "close_a11y": "Đóng" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Bạn đã thắng", - "description": "Nhận giải thưởng của bạn ngay hôm nay.", - "a11y": "Mở thông tin người chiến thắng" + "title": "Bạn đã thắng!", + "description": "Xác minh mã trúng thưởng và nhận giải thưởng hôm nay.", + "a11y": "Xem chi tiết" }, "participant_pending": { "title": "Chiến dịch đã kết thúc.", "description": "Chúng tôi đang xác định kết quả. Hãy quay lại sau." }, "participant_finalized": { - "title": "Kết quả chiến dịch đã có", + "title": "Đã có kết quả chiến dịch.", "description": "Lần này bạn đã không giành chiến thắng. Hãy kiểm tra bảng xếp hạng để xem bạn đứng ở vị trí nào." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Phần thưởng của bạn đang được gửi — chúng tôi sẽ sớm liên hệ với bạn." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Bạn đã thắng 💰", - "description": "Nhận giải thưởng của bạn từ {{campaignName}}", + "description": "Nhận thưởng từ {{campaignName}} hôm nay.", "cta": "Xem chi tiết" }, - "participant_no_winner": { - "title": "Kết quả đã có. 🏅", - "description": "Xem thứ hạng của bạn trong {{campaignName}}", + "non_winner": { + "title": "Đã có kết quả 🏅", + "description": "Xem thứ hạng của bạn trong {{campaignName}}.", "cta": "Xem" } + }, + "campaign_winning": { + "you_won": "Bạn đã thắng", + "rank_label": "Hạng {{place}}", + "email_instructions": "Gửi email đến {{email}} kèm mã của bạn để nhận thưởng.", + "open_mail": "Mở thư", + "skip_for_now": "Bỏ qua bây giờ", + "mail_subject": "{{campaignName}} – nhận thưởng", + "mail_body": "Mã trúng thưởng của tôi: {{code}}", + "winning_code": "Mã trúng thưởng", + "close_a11y": "Đóng" } }, "time": { diff --git a/locales/languages/zh.json b/locales/languages/zh.json index bee8f24db28..f7a0a2db289 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -8658,29 +8658,18 @@ "retry_button": "重试", "refreshing": "正在刷新……" }, - "ondo_campaign_winning": { - "you_won": "您已获胜", - "rank_label": "第 {{place}} 名", - "email_instructions": "请将您的代码发送至 ondocampaign@consensys.net 领取奖品。", - "open_mail": "打开邮件", - "skip_for_now": "暂时跳过", - "mail_subject": "领取 Ondo 活动奖品", - "mail_body": "我的获奖码:{{code}}", - "winning_code": "获奖码", - "close_a11y": "关闭" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "您已获胜", - "description": "立即领取您的奖品。", - "a11y": "打开获奖者详情" + "title": "您中奖了!", + "description": "验证您的中奖代码,并在今天领取奖品。", + "a11y": "查看详情" }, "participant_pending": { "title": "活动已结束。", "description": "我们正在确定结果。请稍后再来查看。" }, "participant_finalized": { - "title": "活动结果已出炉", + "title": "活动结果已公布。", "description": "您这次未中奖。查看排行榜了解您的最终排名。" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "您的奖励正在发放中——我们将很快联系您。" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "您已获胜 💰", - "description": "领取您在 {{campaignName}} 中的奖品", + "description": "今天从 {{campaignName}} 领取您的奖品。", "cta": "查看详情" }, - "participant_no_winner": { - "title": "结果已揭晓。🏅", - "description": "查看您在 {{campaignName}} 中的排名", + "non_winner": { + "title": "结果已公布 🏅", + "description": "查看您在 {{campaignName}} 中的排名。", "cta": "查看" } + }, + "campaign_winning": { + "you_won": "您已获胜", + "rank_label": "第 {{place}} 名", + "email_instructions": "请将您的代码发送至 {{email}} 领取奖品。", + "open_mail": "打开邮件", + "skip_for_now": "暂时跳过", + "mail_subject": "{{campaignName}} 奖品领取", + "mail_body": "我的获奖码:{{code}}", + "winning_code": "获奖码", + "close_a11y": "关闭" } }, "time": { From c554c5e78cd7e671311a603d7d93d138a67ccf6d Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 5 May 2026 20:54:12 -0400 Subject: [PATCH 02/10] Fix winning view --- .../Views/CampaignWinningView.test.tsx | 20 ++-- .../UI/Rewards/Views/CampaignWinningView.tsx | 50 ++++++++-- .../Views/OndoCampaignWinningView.test.tsx | 41 +++++---- .../Rewards/Views/OndoCampaignWinningView.tsx | 60 ++---------- .../PerpsTradingCampaignWinningView.test.tsx | 92 ++++++++++++++----- .../Views/PerpsTradingCampaignWinningView.tsx | 49 ++++------ .../ReferralDetails/CopyableField.tsx | 10 +- 7 files changed, 170 insertions(+), 152 deletions(-) diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx index 198cee4e545..072e47daa44 100644 --- a/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx @@ -110,14 +110,11 @@ jest.mock('../../../../../locales/i18n', () => ({ ( key: string, params?: { - place?: string; code?: string; campaignName?: string; email?: string; }, ) => { - if (key === 'rewards.campaign_winning.rank_label' && params?.place) - return `${params.place} place`; if ( key === 'rewards.campaign_winning.mail_subject' && params?.campaignName @@ -161,7 +158,7 @@ const defaultProps: CampaignWinningViewProps = { winningCode: WINNING_CODE, hasOutcomeLoaded: true, isLoading: false, - renderRankSection: () => null, + rankDisplay: null, }; describe('CampaignWinningView', () => { @@ -192,19 +189,16 @@ describe('CampaignWinningView', () => { }); }); - it('renders the renderRankSection slot content', () => { - const { getByTestId } = render( + it('renders rank and result display when provided', () => { + const { getByText } = render( ( - - {/* eslint-disable-next-line react-native/no-inline-styles */} - - - )} + rankDisplay="3rd" + resultDisplay="+12.34%" />, ); - expect(getByTestId('test-winning-view')).toBeTruthy(); + expect(getByText('3rd')).toBeTruthy(); + expect(getByText('+12.34%')).toBeTruthy(); }); it('calls goBack when Skip for now is pressed', () => { diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.tsx index 2c40fce4fec..84789592569 100644 --- a/app/components/UI/Rewards/Views/CampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/CampaignWinningView.tsx @@ -16,9 +16,11 @@ import { ButtonIcon, ButtonIconSize, IconName, + Skeleton, Text, TextColor, TextVariant, + FontWeight, } from '@metamask/design-system-react-native'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; @@ -41,7 +43,10 @@ export interface CampaignWinningViewProps { winningCode: string | null; hasOutcomeLoaded: boolean; isLoading: boolean; - renderRankSection: () => React.ReactNode; + rankDisplay: string | null; + resultDisplay?: string | null; + isRankLoading?: boolean; + isResultLoading?: boolean; } const CampaignWinningView: React.FC = ({ @@ -54,7 +59,10 @@ const CampaignWinningView: React.FC = ({ winningCode, hasOutcomeLoaded, isLoading, - renderRankSection, + rankDisplay, + resultDisplay = null, + isRankLoading = false, + isResultLoading = false, }) => { const tw = useTailwind(); const { height: windowHeight } = useWindowDimensions(); @@ -150,17 +158,48 @@ const CampaignWinningView: React.FC = ({ {strings('rewards.campaign_winning.you_won')} - {renderRankSection()} + + {rankDisplay !== null ? ( + + {rankDisplay} + + ) : isRankLoading ? ( + + ) : null} + + {resultDisplay !== null ? ( + + {resultDisplay} + + ) : isResultLoading ? ( + + ) : null} + = ({ { const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: jest.fn( - ({ - testID, - renderRankSection, - }: { - testID: string; - renderRankSection: () => React.ReactNode; - }) => ReactActual.createElement(View, { testID }, renderRankSection?.()), + default: jest.fn(({ testID }: { testID: string }) => + ReactActual.createElement(View, { testID }), ), }; }); @@ -95,6 +89,10 @@ describe('OndoCampaignWinningView', () => { winningCode: 'ONDO-WIN-99', hasOutcomeLoaded: true, isLoading: false, + rankDisplay: null, + resultDisplay: null, + isRankLoading: false, + isResultLoading: false, }), {}, ); @@ -136,27 +134,32 @@ describe('OndoCampaignWinningView', () => { ); }); - it('renderRankSection renders rank and rate when position is available', () => { + it('passes rank and result display when position is available', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'ONDO-WIN-99', + tierRank: 3, + }, + isLoading: false, + hasError: false, + }); mockUsePosition.mockReturnValue({ - position: { rank: 3, rateOfReturn: 0.1234 } as never, + position: { rank: 9, rateOfReturn: 0.1234 } as never, isLoading: false, hasError: false, hasFetched: true, refetch: jest.fn(), }); - jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, params?: { place?: string }) => { - if (key === 'rewards.campaign_winning.rank_label' && params?.place) - return `${params.place} place`; - return key; - }), - })); - render(); expect(mockCampaignWinningView).toHaveBeenCalledWith( expect.objectContaining({ - renderRankSection: expect.any(Function), + rankDisplay: '3rd', + resultDisplay: '+12.34%', + isRankLoading: false, + isResultLoading: false, }), {}, ); diff --git a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx index 33d8f6c45e9..04e868b3d34 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx @@ -1,16 +1,6 @@ import React, { useMemo } from 'react'; import { useRoute, RouteProp } from '@react-navigation/native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - BoxFlexDirection, - Skeleton, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; -import { strings } from '../../../../../locales/i18n'; import { formatOrdinalRank, formatPercentChange } from '../utils/formatUtils'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; import CampaignWinningView from './CampaignWinningView'; @@ -28,7 +18,6 @@ export const ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS = { } as const; const OndoCampaignWinningView: React.FC = () => { - const tw = useTailwind(); const route = useRoute< RouteProp @@ -43,51 +32,15 @@ const OndoCampaignWinningView: React.FC = () => { const winningCode = outcome?.winnerVerificationCode ?? null; const rankDisplay = useMemo(() => { - if (!position) return null; - return strings('rewards.campaign_winning.rank_label', { - place: formatOrdinalRank(position.rank), - }); - }, [position]); + if (!outcome?.tierRank) return null; + return formatOrdinalRank(outcome.tierRank); + }, [outcome]); - const rateDisplay = useMemo(() => { + const resultDisplay = useMemo(() => { if (!position) return null; return formatPercentChange(position.rateOfReturn); }, [position]); - const renderRankSection = () => { - if (!positionLoading && !position) return null; - return ( - - {rankDisplay !== null ? ( - - {rankDisplay} - - ) : ( - - )} - - {rateDisplay !== null ? ( - - {rateDisplay} - - ) : ( - - )} - - ); - }; - return ( { winningCode={winningCode} hasOutcomeLoaded={Boolean(outcome)} isLoading={isOutcomeLoading} - renderRankSection={renderRankSection} + rankDisplay={rankDisplay} + resultDisplay={resultDisplay} + isRankLoading={isOutcomeLoading} + isResultLoading={positionLoading} /> ); }; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx index 0bb68a23875..d6e7bf41fc2 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx @@ -4,6 +4,7 @@ import PerpsTradingCampaignWinningView, { PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS, } from './PerpsTradingCampaignWinningView'; import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import CampaignWinningView from './CampaignWinningView'; jest.mock('./CampaignWinningView', () => { @@ -11,14 +12,8 @@ jest.mock('./CampaignWinningView', () => { const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: jest.fn( - ({ - testID, - renderRankSection, - }: { - testID: string; - renderRankSection: () => React.ReactNode; - }) => ReactActual.createElement(View, { testID }, renderRankSection?.()), + default: jest.fn(({ testID }: { testID: string }) => + ReactActual.createElement(View, { testID }), ), }; }); @@ -27,6 +22,10 @@ jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ usePerpsTradingCampaignParticipantOutcome: jest.fn(), })); +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition', () => ({ + useGetPerpsTradingCampaignLeaderboardPosition: jest.fn(), +})); + jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), useRoute: () => ({ @@ -40,18 +39,14 @@ jest.mock('@metamask/design-system-twrnc-preset', () => { return { useTailwind: () => tw }; }); -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, params?: { place?: string }) => { - if (key === 'rewards.campaign_winning.rank_label' && params?.place) - return `${params.place} place`; - return key; - }), -})); - const mockUseOutcome = usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< typeof usePerpsTradingCampaignParticipantOutcome >; +const mockUsePosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< typeof CampaignWinningView >; @@ -69,6 +64,21 @@ describe('PerpsTradingCampaignWinningView', () => { isLoading: false, hasError: false, }); + mockUsePosition.mockReturnValue({ + position: { + rank: 3, + pnl: 1500.25, + notionalVolume: 30000, + marginDeployed: 1200, + qualified: true, + neighbors: [], + computedAt: '2025-08-15T12:00:00.000Z', + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); }); it('renders the container with the Perps testID', () => { @@ -90,6 +100,10 @@ describe('PerpsTradingCampaignWinningView', () => { winningCode: 'PERPS-WIN-99', hasOutcomeLoaded: true, isLoading: false, + rankDisplay: '3rd', + resultDisplay: '+$1,500.25', + isRankLoading: false, + isResultLoading: false, }), {}, ); @@ -132,15 +146,39 @@ describe('PerpsTradingCampaignWinningView', () => { ); }); - it('renderRankSection shows rank when available', () => { + it('passes rankDisplay when rank is available', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); + }); + + it('passes rank from outcome and no result when position is unavailable', () => { + mockUsePosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); render(); - const { renderRankSection } = mockCampaignWinningView.mock.calls[0][0]; - expect(renderRankSection).toBeDefined(); - const section = renderRankSection(); - expect(section).toBeTruthy(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + resultDisplay: null, + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); }); - it('renderRankSection shows dash when outcome has no rank', () => { + it('does not pass rankDisplay when outcome has no rank', () => { mockUseOutcome.mockReturnValue({ outcome: { subscriptionId: 'sub-1', @@ -152,8 +190,12 @@ describe('PerpsTradingCampaignWinningView', () => { hasError: false, }); render(); - const { renderRankSection } = mockCampaignWinningView.mock.calls[0][0]; - // When rank is null, rankDisplay = '—' - expect(renderRankSection).toBeDefined(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: null, + isRankLoading: false, + }), + {}, + ); }); }); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx index 0eca7d70f8b..44f85307136 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx @@ -1,15 +1,8 @@ import React, { useMemo } from 'react'; import { useRoute, RouteProp } from '@react-navigation/native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Skeleton, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; -import { strings } from '../../../../../locales/i18n'; -import { formatOrdinalRank } from '../utils/formatUtils'; +import { formatOrdinalRank, formatSignedUsd } from '../utils/formatUtils'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import CampaignWinningView from './CampaignWinningView'; const PRIZE_EMAIL = 'perpscampaign@consensys.net'; @@ -27,7 +20,6 @@ export const PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS = { } as const; const PerpsTradingCampaignWinningView: React.FC = () => { - const tw = useTailwind(); const route = useRoute< RouteProp< @@ -41,32 +33,20 @@ const PerpsTradingCampaignWinningView: React.FC = () => { usePerpsTradingCampaignParticipantOutcome(campaignId); const winningCode = outcome?.winnerVerificationCode ?? null; + const { position, isLoading: positionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition(campaignId); + const rankDisplay = useMemo(() => { - if (isOutcomeLoading && !outcome) { - return null; - } if (!outcome?.rank) { - return '—'; + return null; } - return strings('rewards.campaign_winning.rank_label', { - place: formatOrdinalRank(outcome.rank), - }); - }, [outcome, isOutcomeLoading]); + return formatOrdinalRank(outcome.rank); + }, [outcome]); - const renderRankSection = () => { - if (rankDisplay === null) { - return ; - } - return ( - - {rankDisplay} - - ); - }; + const resultDisplay = useMemo(() => { + if (!position) return null; + return formatSignedUsd(position.pnl); + }, [position]); return ( { winningCode={winningCode} hasOutcomeLoaded={Boolean(outcome)} isLoading={isOutcomeLoading} - renderRankSection={renderRankSection} + rankDisplay={rankDisplay} + resultDisplay={resultDisplay} + isRankLoading={isOutcomeLoading} + isResultLoading={positionLoading} /> ); }; diff --git a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx index 7023d15b341..6bf5b7a0cc9 100644 --- a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx +++ b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx @@ -14,7 +14,7 @@ import { import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; interface CopyableFieldProps { - label: string; + label?: string; value?: string | null; onCopy?: () => void; valueLoading?: boolean; @@ -40,9 +40,11 @@ const CopyableField: React.FC = ({ return ( - - {label} - + {label && ( + + {label} + + )} Date: Tue, 5 May 2026 21:02:57 -0400 Subject: [PATCH 03/10] Update RewardsNavigator.tsx --- app/components/UI/Rewards/RewardsNavigator.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index ce1300a8dc0..78b4bff2db8 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -300,6 +300,7 @@ const RewardsNavigator: React.FC = () => { Date: Tue, 5 May 2026 21:07:27 -0400 Subject: [PATCH 04/10] Fix non-winner route --- .../hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts | 6 +++--- .../hooks/usePerpsTradingCampaignEndedOutcomeToast.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts index e2f2e1ba483..44c7d775a5c 100644 --- a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts @@ -85,14 +85,14 @@ describe('usePerpsTradingCampaignEndedOutcomeToast', () => { }); }); - it('getNonWinnerNavigation returns campaigns view route', () => { + it('getNonWinnerNavigation returns Perps details view route', () => { renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); const { getNonWinnerNavigation } = mockUseCampaignOutcomeToast.mock.calls[0][0]; const nav = getNonWinnerNavigation(makeCampaign()); expect(nav).toEqual({ - route: Routes.REWARDS_CAMPAIGNS_VIEW, - params: {}, + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: CAMPAIGN_ID }, }); }); }); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts index 93dbe8bc2be..ae99229b1ba 100644 --- a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts @@ -11,9 +11,9 @@ export function usePerpsTradingCampaignEndedOutcomeToast(): void { route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, }), - getNonWinnerNavigation: () => ({ - route: Routes.REWARDS_CAMPAIGNS_VIEW, - params: {}, + getNonWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: campaign.id }, }), }); } From 82267700bc446c8226d7010cac4884ec361fad05 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 5 May 2026 22:20:34 -0400 Subject: [PATCH 05/10] Dedup selectCampaignParticipantOptedIn --- .../PerpsTradingCampaignDetailsView.test.tsx | 164 +++++++++++++++++- .../Views/PerpsTradingCampaignDetailsView.tsx | 52 +++++- .../PerpsCampaignStatsSummary.test.tsx | 62 ++++++- .../Campaigns/PerpsCampaignStatsSummary.tsx | 16 ++ .../useCampaignParticipantOutcome.test.ts | 21 +-- .../hooks/useCampaignParticipantOutcome.ts | 8 +- .../hooks/useGetOndoCampaignActivity.test.ts | 8 +- .../hooks/useGetOndoCampaignActivity.ts | 6 +- .../useGetOndoLeaderboardPosition.test.ts | 16 +- .../hooks/useGetOndoPortfolioPosition.test.ts | 8 +- .../hooks/useGetOndoPortfolioPosition.ts | 6 +- ...TradingCampaignLeaderboardPosition.test.ts | 16 +- .../Rewards/hooks/useOptInToCampaign.test.ts | 31 +++- .../UI/Rewards/hooks/useOptInToCampaign.ts | 16 +- app/reducers/rewards/selectors.test.ts | 34 ++++ app/reducers/rewards/selectors.ts | 9 + app/selectors/rewards/index.ts | 11 -- 17 files changed, 418 insertions(+), 66 deletions(-) diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx index 54fe6b66bee..5e65f2d9a3c 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx @@ -2,18 +2,21 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import PerpsTradingCampaignDetailsView, { PERPS_CAMPAIGN_DETAILS_TEST_IDS, + resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests, } from './PerpsTradingCampaignDetailsView'; import { type CampaignDto, CampaignType, type PerpsTradingCampaignLeaderboardEntry, type PerpsTradingCampaignLeaderboardPositionDto, + type PerpsTradingCampaignParticipantOutcomeDto, } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import Routes from '../../../../constants/navigation/Routes'; const mockGoBack = jest.fn(); @@ -24,6 +27,7 @@ const mockRouteState: { params: { campaignId?: string } } = { }; jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (callback: () => void) => callback(), useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate, @@ -137,13 +141,34 @@ jest.mock('../components/Campaigns/CampaignHowItWorks', () => { jest.mock('../components/Campaigns/PerpsCampaignStatsSummary', () => { const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); + const { Pressable, Text, View } = jest.requireActual('react-native'); return { __esModule: true, - default: () => - ReactActual.createElement(View, { - testID: 'perps-campaign-stats-summary-container', - }), + default: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus?: string; + winnerVerificationCode?: string | null; + onWinnerPress?: () => void; + }) => + ReactActual.createElement( + View, + { + testID: 'perps-campaign-stats-summary-container', + }, + outcomeStatus && + onWinnerPress && + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + ), }; }); @@ -247,6 +272,12 @@ const mockUseGetPerpsTradingCampaignVolume = typeof useGetPerpsTradingCampaignVolume >; +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome'); +const mockUsePerpsTradingCampaignParticipantOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; + import { useSelector } from 'react-redux'; import { selectReferralCode } from '../../../../reducers/rewards/selectors'; @@ -317,6 +348,7 @@ function setupHooks( participant?: { optedIn: boolean }; position?: { rank: number; neighbors: unknown[] } | null; totalParticipants?: number; + outcome?: PerpsTradingCampaignParticipantOutcomeDto | null; } = {}, ) { const { @@ -326,6 +358,7 @@ function setupHooks( participant = { optedIn: false }, position = null, totalParticipants: totalParticipantsOverride, + outcome = null, } = overrides; mockUseRewardCampaigns.mockReturnValue({ @@ -370,6 +403,12 @@ function setupHooks( mockUseGetPerpsTradingCampaignVolume.mockReturnValue({ ...defaultVolumeHook, } as ReturnType); + + mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({ + outcome, + isLoading: false, + hasError: false, + } as ReturnType); } jest.mock('../../../../../locales/i18n', () => ({ @@ -398,6 +437,7 @@ describe('PerpsTradingCampaignDetailsView', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); jest.clearAllMocks(); + resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(); mockRouteState.params = { campaignId: 'perps-campaign-1' }; mockUseSelector.mockImplementation((selector) => { if (selector === selectReferralCode) { @@ -552,6 +592,120 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(queryByTestId('perps-trading-cta')).toBeNull(); }); + it('shows outcome banner for completed opted-in participants and navigates winners to winning view', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + const { getByTestId } = render(); + + fireEvent.press( + getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + { + campaignId: 'perps-campaign-1', + campaignName: 'Perps Trading', + }, + ); + }); + + it('auto-navigates once to winning view for a completed pending winner outcome', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + const { rerender } = render(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + { + campaignId: 'perps-campaign-1', + campaignName: 'Perps Trading', + }, + ); + + mockNavigate.mockClear(); + rerender(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not auto-navigate for finalized outcomes', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'finalized', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + render(); + + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + expect.any(Object), + ); + }); + + it('does not show outcome banner outside the stats summary section', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: null, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + + const { queryByTestId } = render(); + + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + expect(queryByTestId('campaign-outcome-banner-finalized-null')).toBeNull(); + }); + it('displays total participant count when the leaderboard reports participants', () => { setupHooks({ totalParticipants: 1500 }); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx index 0310b69d8ea..7863d37119a 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx @@ -1,6 +1,11 @@ import React, { useCallback, useMemo } from 'react'; import { Pressable, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { + useFocusEffect, + useNavigation, + useRoute, + RouteProp, +} from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box, @@ -33,6 +38,7 @@ import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticip import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; @@ -53,6 +59,11 @@ export const PERPS_CAMPAIGN_DETAILS_TEST_IDS = { CONTAINER: 'perps-campaign-details-container', } as const; +const sessionWinningViewAutoNavCampaignIds = new Set(); +export function resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(): void { + sessionWinningViewAutoNavCampaignIds.clear(); +} + const PerpsTradingCampaignDetailsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); @@ -106,6 +117,10 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { const { position } = useGetPerpsTradingCampaignLeaderboardPosition( isOptedIn ? effectiveCampaignId || undefined : undefined, ); + const { outcome: participantOutcome } = + usePerpsTradingCampaignParticipantOutcome( + isComplete && isOptedIn ? effectiveCampaignId || undefined : undefined, + ); const { volume, @@ -163,6 +178,36 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { }); }, [navigation, effectiveCampaignId]); + const navigateToWinningView = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, { + campaignId: effectiveCampaignId, + campaignName: campaign?.name ?? '', + }); + }, [navigation, effectiveCampaignId, campaign]); + + useFocusEffect( + useCallback(() => { + if ( + !sessionWinningViewAutoNavCampaignIds.has(effectiveCampaignId) && + campaign && + isComplete && + participantOutcome?.winnerVerificationCode && + participantOutcome?.outcomeStatus === 'pending' && + effectiveCampaignId + ) { + sessionWinningViewAutoNavCampaignIds.add(effectiveCampaignId); + navigateToWinningView(); + } + }, [ + campaign, + effectiveCampaignId, + isComplete, + navigateToWinningView, + participantOutcome, + ]), + ); + const navigateToMechanics = useCallback(() => { if (!effectiveCampaignId) return; navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { @@ -258,6 +303,11 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { leaderboardPosition={position} leaderboard={leaderboard} isCampaignComplete={isComplete} + outcomeStatus={participantOutcome?.outcomeStatus} + winnerVerificationCode={ + participantOutcome?.winnerVerificationCode ?? null + } + onWinnerPress={navigateToWinningView} /> )} diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx index 4039d10506b..23031670dff 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { fireEvent, render } from '@testing-library/react-native'; import { TextColor } from '@metamask/design-system-react-native'; import PerpsCampaignStatsSummary, { PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS, @@ -25,6 +25,30 @@ jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, })); +jest.mock('./CampaignOutcomeBanners', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + CampaignOutcomeBanner: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus: string; + winnerVerificationCode?: string | null; + onWinnerPress: () => void; + }) => + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + }; +}); + const TEST_IDS = PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS; const mockLeaderboard = { @@ -203,4 +227,40 @@ describe('PerpsCampaignStatsSummary', () => { ); expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); }); + + it('shows outcome banner for complete campaigns and handles winner press', () => { + const onWinnerPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ); + expect(onWinnerPress).toHaveBeenCalledTimes(1); + }); + + it('does not show outcome banner before campaign completion', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ).toBeNull(); + }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx index 16a2608dfa0..3f80c2b1621 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx @@ -13,6 +13,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import type { + CampaignParticipantOutcomeStatus, PerpsTradingCampaignLeaderboardDto, PerpsTradingCampaignLeaderboardPositionDto, } from '../../../../../core/Engine/controllers/rewards-controller/types'; @@ -20,6 +21,7 @@ import { strings } from '../../../../../../locales/i18n'; import { formatSignedUsd, formatUsd } from '../../utils/formatUtils'; import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../../utils/perpsCampaignConstants'; import { PendingTag, StatCell } from './OndoCampaignStatsSummary'; +import { CampaignOutcomeBanner } from './CampaignOutcomeBanners'; const PERPS_NOTIONAL_THRESHOLD_LABEL = formatUsd( PERPS_QUALIFICATION_NOTIONAL_USD, @@ -43,12 +45,18 @@ export interface PerpsCampaignStatsSummaryProps { leaderboard: PerpsTradingCampaignLeaderboardDto | null; /** When false, pending (not yet qualified) users see a {@link PendingTag} next to rank. */ isCampaignComplete?: boolean; + outcomeStatus?: CampaignParticipantOutcomeStatus; + winnerVerificationCode?: string | null; + onWinnerPress?: () => void; } const PerpsCampaignStatsSummary: React.FC = ({ leaderboardPosition, leaderboard: _leaderboard, isCampaignComplete = false, + outcomeStatus, + winnerVerificationCode, + onWinnerPress, }) => { const isPending = leaderboardPosition != null && !leaderboardPosition.qualified; @@ -180,6 +188,14 @@ const PerpsCampaignStatsSummary: React.FC = ({ )} + + {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && ( + + )} ); }; diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts index 3f40088948e..012cbe4549a 100644 --- a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts @@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; +import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors'; import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -19,16 +19,16 @@ jest.mock('../../../../selectors/rewards', () => ({ })); jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaignParticipantStatus: jest.fn(), + selectCampaignParticipantOptedIn: jest.fn(), })); const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< typeof Engine.controllerMessenger.call >; const mockUseSelector = useSelector as jest.MockedFunction; -const mockSelectCampaignParticipantStatus = - selectCampaignParticipantStatus as jest.MockedFunction< - typeof selectCampaignParticipantStatus +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn >; const CAMPAIGN_ID = 'campaign-123'; @@ -49,16 +49,13 @@ function setupSelectors({ subscriptionId?: string | null; isOptedIn?: boolean; } = {}) { - const participantStatusSelector = jest - .fn() - .mockReturnValue(isOptedIn ? { optedIn: true } : null); - mockSelectCampaignParticipantStatus.mockReturnValue( - participantStatusSelector, + const participantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + participantOptedInSelector, ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return subscriptionId; - if (selector === participantStatusSelector) - return isOptedIn ? { optedIn: true } : null; + if (selector === participantOptedInSelector) return isOptedIn; return undefined; }); } diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts index b094725896e..1f10639c665 100644 --- a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; +import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors'; import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; export interface UseCampaignParticipantOutcomeResult< @@ -24,9 +24,9 @@ export function useCampaignParticipantOutcome< config: CampaignOutcomeFetchConfig, ): UseCampaignParticipantOutcomeResult { const subscriptionId = useSelector(selectRewardsSubscriptionId); - const isOptedIn = - useSelector(selectCampaignParticipantStatus(subscriptionId, campaignId)) - ?.optedIn === true; + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); const [outcome, setOutcome] = useState(null); const [isLoading, setIsLoading] = useState(false); const [hasError, setHasError] = useState(false); diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts index c0bed6ad4a6..f424932ae16 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts @@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoCampaignActivity } from './useGetOndoCampaignActivity'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignActivityById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignActivity } from '../../../../reducers/rewards'; import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -22,10 +22,10 @@ jest.mock('../../../../core/Engine', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignActivityById: jest.fn(), })); diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts index 3c28894340a..92faacd5610 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignActivityById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignActivity } from '../../../../reducers/rewards'; import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types'; diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts index f3170271b60..2910af46256 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts @@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoLeaderboardPosition } from './useGetOndoLeaderboardPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignLeaderboardPositionById: jest.fn(), })); @@ -83,16 +83,18 @@ interface SelectorState { function setupSelectors(state: SelectorState) { const isOptedIn = state.isOptedIn ?? true; const mockPositionSelector = jest.fn().mockReturnValue(state.position); - const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); mockSelectCampaignLeaderboardPositionById.mockReturnValue( mockPositionSelector, ); - mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + mockParticipantOptedInSelector, + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return state.subscriptionId; if (selector === mockPositionSelector) return state.position; - if (selector === mockOptedInSelector) return isOptedIn; + if (selector === mockParticipantOptedInSelector) return isOptedIn; return undefined; }); } diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts index 80300f1f62c..332ad4b7415 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts @@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoPortfolioPosition } from './useGetOndoPortfolioPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignPortfolioById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -27,10 +27,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignPortfolioById: jest.fn(), })); diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts index 2270b597a59..e0f960916cc 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignPortfolioById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts index 320a3921c75..d85a3b027cf 100644 --- a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts @@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector, useDispatch } from 'react-redux'; import { useGetPerpsTradingCampaignLeaderboardPosition } from './useGetPerpsTradingCampaignLeaderboardPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectPerpsTradingCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectPerpsTradingCampaignLeaderboardPositionById: jest.fn(), })); @@ -79,14 +79,16 @@ interface SelectorState { function setupSelectors(state: SelectorState) { const isOptedIn = state.isOptedIn ?? true; const mockPositionSelector = jest.fn().mockReturnValue(state.position); - const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); mockSelectPositionById.mockReturnValue(mockPositionSelector); - mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + mockParticipantOptedInSelector, + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return state.subscriptionId; if (selector === mockPositionSelector) return state.position; - if (selector === mockOptedInSelector) return isOptedIn; + if (selector === mockParticipantOptedInSelector) return isOptedIn; return undefined; }); } diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts index c94087723b0..e97add67a7e 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts @@ -1,10 +1,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useOptInToCampaign } from './useOptInToCampaign'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), useSelector: jest.fn(), })); @@ -16,10 +18,22 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../reducers/rewards', () => ({ + setCampaignParticipantStatus: jest.fn((payload) => ({ + type: 'rewards/setCampaignParticipantStatus', + payload, + })), +})); + const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< typeof Engine.controllerMessenger.call >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSetCampaignParticipantStatus = + setCampaignParticipantStatus as unknown as jest.MockedFunction< + typeof setCampaignParticipantStatus + >; const SUB_ID = 'sub-123'; const CAMPAIGN_ID = 'camp-456'; @@ -33,8 +47,11 @@ function setupSelectors(subscriptionId: string | null) { } describe('useOptInToCampaign', () => { + const mockDispatch = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); }); it('returns null when subscriptionId is missing', async () => { @@ -63,6 +80,18 @@ describe('useOptInToCampaign', () => { CAMPAIGN_ID, SUB_ID, ); + expect(mockDispatch).toHaveBeenCalledWith( + setCampaignParticipantStatus({ + subscriptionId: SUB_ID, + campaignId: CAMPAIGN_ID, + status: STATUS, + }), + ); + expect(mockSetCampaignParticipantStatus).toHaveBeenCalledWith({ + subscriptionId: SUB_ID, + campaignId: CAMPAIGN_ID, + status: STATUS, + }); expect(returnValue).toEqual(STATUS); expect(result.current.isOptingIn).toBe(false); expect(result.current.optInError).toBeUndefined(); diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts index b3852646997..a0533e7889f 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts @@ -1,7 +1,8 @@ import { useCallback, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types'; export interface UseOptInToCampaignResult { @@ -22,6 +23,7 @@ export interface UseOptInToCampaignResult { */ export const useOptInToCampaign = (): UseOptInToCampaignResult => { const subscriptionId = useSelector(selectRewardsSubscriptionId); + const dispatch = useDispatch(); const [isOptingIn, setIsOptingIn] = useState(false); const [optInError, setOptInError] = useState(undefined); @@ -36,11 +38,19 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => { try { setIsOptingIn(true); setOptInError(undefined); - return await Engine.controllerMessenger.call( + const result = await Engine.controllerMessenger.call( 'RewardsController:optInToCampaign', campaignId, subscriptionId, ); + dispatch( + setCampaignParticipantStatus({ + subscriptionId, + campaignId, + status: result, + }), + ); + return result; } catch (error) { const message = error instanceof Error ? error.message : 'Opt-in failed'; @@ -50,7 +60,7 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => { setIsOptingIn(false); } }, - [subscriptionId], + [dispatch, subscriptionId], ); const clearOptInError = useCallback(() => setOptInError(undefined), []); diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index d703e8d15f6..9519b4cb778 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -51,6 +51,7 @@ import { selectCampaignsError, selectCampaignParticipantStatuses, selectCampaignParticipantStatus, + selectCampaignParticipantOptedIn, selectCampaignParticipantCount, selectIsRewardsVersionBlocked, selectVersionGuardMinimumMobileVersion, @@ -3285,6 +3286,39 @@ describe('Rewards selectors', () => { }); }); + describe('selectCampaignParticipantOptedIn', () => { + it('returns true when participant status is opted in', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'sub-1:campaign-1': { optedIn: true, participantCount: 42 }, + }, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(true); + }); + + it('returns false when participant status is not opted in', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'sub-1:campaign-1': { optedIn: false, participantCount: 0 }, + }, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(false); + }); + + it('returns false when status is missing', () => { + const state = createMockRootState({ + campaignParticipantStatuses: {}, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(false); + }); + }); + describe('selectCampaignParticipantCount', () => { it('returns null when subscriptionId is undefined', () => { const state = createMockRootState({ diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index 78fc6bc1d76..83c12f18cc9 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -188,6 +188,15 @@ export const selectCampaignParticipantStatus = return state.rewards.campaignParticipantStatuses?.[key] ?? null; }; +export const selectCampaignParticipantOptedIn = + ( + subscriptionId: string | undefined | null, + campaignId: string | undefined | null, + ) => + (state: RootState): boolean => + selectCampaignParticipantStatus(subscriptionId, campaignId)(state) + ?.optedIn === true; + export const selectCampaignParticipantCount = (subscriptionId: string | undefined, campaignId: string | undefined) => (state: RootState) => { diff --git a/app/selectors/rewards/index.ts b/app/selectors/rewards/index.ts index c4475cbb113..ca99456407f 100644 --- a/app/selectors/rewards/index.ts +++ b/app/selectors/rewards/index.ts @@ -43,17 +43,6 @@ export const selectRewardsSubscriptionId = createSelector( }, ); -export const selectCampaignParticipantOptedIn = - (subscriptionId: string | null, campaignId: string | undefined) => - (state: RootState): boolean => { - if (!subscriptionId || !campaignId) return false; - return ( - state.engine.backgroundState.RewardsController.campaignParticipantStatus[ - `${subscriptionId}:${campaignId}` - ]?.optedIn === true - ); - }; - export const selectRewardsActiveAccountAddress = createSelector( selectRewardsControllerState, (rewardsControllerState): string | null => { From 55f3fd727b716b64ad3df79fae3b63c809360192 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 5 May 2026 22:31:16 -0400 Subject: [PATCH 06/10] Mount usePerpsOutcome --- .../UI/Rewards/RewardsNavigator.test.tsx | 8 ------ .../UI/Rewards/RewardsNavigator.tsx | 6 ----- .../UI/Rewards/Views/CampaignsView.test.tsx | 26 +++++++++++++++++++ .../UI/Rewards/Views/CampaignsView.tsx | 4 +++ .../Rewards/Views/RewardsDashboard.test.tsx | 26 +++++++++++++++++++ .../UI/Rewards/Views/RewardsDashboard.tsx | 5 ++++ 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index 4dae365e704..b824e326b1c 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -255,14 +255,6 @@ jest.mock('./hooks/useReferralDetails', () => ({ }), })); -jest.mock('./hooks/useOndoOutcomeToast', () => ({ - useOndoOutcomeToast: jest.fn(), -})); - -jest.mock('./hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ - usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), -})); - // Mock useRewardsNotificationsNudge hook const mockShowEnableNotificationsNudge = jest.fn(() => false); const mockCloseEnableNotificationsNudge = jest.fn(); diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index 78b4bff2db8..77ab123834e 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -36,8 +36,6 @@ import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata'; import { useReferralDetails } from './hooks/useReferralDetails'; import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge'; import useRewardsToast from './hooks/useRewardsToast'; -import { useOndoOutcomeToast } from './hooks/useOndoOutcomeToast'; -import { usePerpsTradingCampaignEndedOutcomeToast } from './hooks/usePerpsTradingCampaignEndedOutcomeToast'; import { strings } from '../../../../locales/i18n'; import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView'; @@ -76,10 +74,6 @@ const RewardsNavigator: React.FC = () => { // Fetch referral details so referral code is available across all rewards screens useReferralDetails(); - // Outcome toasts for all campaign types — mounted once so they are active regardless of which screen is focused - useOndoOutcomeToast(); - usePerpsTradingCampaignEndedOutcomeToast(); - const { showToast, RewardsToastOptions } = useRewardsToast(); const nudgeToastActiveRef = useRef(false); diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx index f7def74d6b8..a811a188841 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx @@ -6,6 +6,8 @@ import { CampaignType, } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; const mockGoBack = jest.fn(); @@ -27,6 +29,21 @@ const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< typeof useRewardCampaigns >; +jest.mock('../hooks/useOndoOutcomeToast', () => ({ + useOndoOutcomeToast: jest.fn(), +})); +const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< + typeof useOndoOutcomeToast +>; + +jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); +const mockUsePerpsTradingCampaignEndedOutcomeToast = + usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction< + typeof usePerpsTradingCampaignEndedOutcomeToast + >; + jest.mock('../components/Campaigns/CampaignsGroup', () => { const ReactActual = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); @@ -176,6 +193,15 @@ describe('CampaignsView', () => { expect(getByText('Campaigns')).toBeOnTheScreen(); }); + it('mounts campaign outcome toast hooks on render', () => { + render(); + + expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); + expect(mockUsePerpsTradingCampaignEndedOutcomeToast).toHaveBeenCalledTimes( + 1, + ); + }); + it('navigates back when the back button is pressed', () => { const { getByTestId } = render(); diff --git a/app/components/UI/Rewards/Views/CampaignsView.tsx b/app/components/UI/Rewards/Views/CampaignsView.tsx index 34786542250..14e5324ba17 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.tsx @@ -20,6 +20,8 @@ import RewardsErrorBanner from '../components/RewardsErrorBanner'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; import CampaignsGroup from '../components/Campaigns/CampaignsGroup'; import { strings } from '../../../../../locales/i18n'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; /** * CampaignsView displays all campaigns organized by status: @@ -32,6 +34,8 @@ const CampaignsView: React.FC = () => { const navigation = useNavigation(); const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = useRewardCampaigns(); + useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); useTrackRewardsPageView({ page_type: 'campaigns_overview' }); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index f4bd4c1685f..27ccd54e53f 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -4,6 +4,8 @@ import { useSelector } from 'react-redux'; import RewardsDashboard from './RewardsDashboard'; import Routes from '../../../../constants/navigation/Routes'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; // Mock dependencies jest.mock('react-redux', () => ({ @@ -166,6 +168,14 @@ jest.mock('../hooks/useBulkLinkState', () => ({ useBulkLinkState: jest.fn(), })); +jest.mock('../hooks/useOndoOutcomeToast', () => ({ + useOndoOutcomeToast: jest.fn(), +})); + +jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); + // Import mocked hooks import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals } from '../hooks/useRewardDashboardModals'; @@ -182,6 +192,13 @@ const mockUseRewardDashboardModals = const mockUseBulkLinkState = useBulkLinkState as jest.MockedFunction< typeof useBulkLinkState >; +const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< + typeof useOndoOutcomeToast +>; +const mockUsePerpsTradingCampaignEndedOutcomeToast = + usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction< + typeof usePerpsTradingCampaignEndedOutcomeToast + >; describe('RewardsDashboard', () => { const mockShowUnlinkedAccountsModal = jest.fn(); @@ -316,6 +333,15 @@ describe('RewardsDashboard', () => { expect(getByText('Rewards')).toBeTruthy(); }); + it('mounts campaign outcome toast hooks on render', () => { + render(); + + expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); + expect( + mockUsePerpsTradingCampaignEndedOutcomeToast, + ).toHaveBeenCalledTimes(1); + }); + it('renders all child components', () => { // Act const { getByTestId } = render(); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index e3e3812877c..3613bd3a6d5 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -29,6 +29,8 @@ import CampaignsPreview from '../components/Campaigns/CampaignsPreview'; import EarnRewardsPreview from '../components/EarnRewards/EarnRewardsPreview'; import BenefitsPreview from '../components/Benefits/BenefitsPreview.tsx'; import { ScrollView } from 'react-native'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; const RewardsDashboard: React.FC = () => { const tw = useTailwind(); @@ -39,6 +41,9 @@ const RewardsDashboard: React.FC = () => { const hasTrackedDashboardViewed = useRef(false); useTrackRewardsPageView({ page_type: 'home' }); + useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); + const hideUnlinkedAccountsBanner = useSelector( selectHideUnlinkedAccountsBanner, ); From 7d128abe621909e7d6cf6df71b3a2daa98ec34e3 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 5 May 2026 23:02:40 -0400 Subject: [PATCH 07/10] Fix lint --- .../UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts | 6 +++--- .../hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts index be4f0b96cc3..9b0bccab4f3 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts index aaa206cc69f..aedd1ff533a 100644 --- a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectPerpsTradingCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; From 6e91da9306c24ee5c8cc92f107e3990a185b9cd7 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 5 May 2026 23:31:01 -0400 Subject: [PATCH 08/10] Add fallback route to winning view --- .../Views/CampaignWinningView.test.tsx | 28 +++++++++++++++++-- .../UI/Rewards/Views/CampaignWinningView.tsx | 19 +++++++++++-- .../Views/OndoCampaignStatsView.test.tsx | 10 +++++-- .../Views/OndoCampaignWinningView.test.tsx | 5 ++++ .../Rewards/Views/OndoCampaignWinningView.tsx | 10 +++++++ .../PerpsTradingCampaignWinningView.test.tsx | 5 ++++ .../Views/PerpsTradingCampaignWinningView.tsx | 10 +++++++ 7 files changed, 79 insertions(+), 8 deletions(-) diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx index 072e47daa44..afbc489f176 100644 --- a/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx @@ -13,9 +13,10 @@ jest.mock('../../../../images/rewards/campaign_winning.png', () => ({ })); const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ goBack: mockGoBack }), + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), })); jest.mock('@metamask/design-system-twrnc-preset', () => { @@ -235,7 +236,30 @@ describe('CampaignWinningView', () => { openSpy.mockRestore(); }); - it('calls goBack when outcome loads without a winning code', () => { + it('navigates to fallback route when outcome loads without a winning code', () => { + const fallbackRoute = { + route: 'CampaignDetails', + params: { campaignId: CAMPAIGN_ID }, + }; + + render( + , + ); + + expect(mockNavigate).toHaveBeenCalledWith( + fallbackRoute.route, + fallbackRoute.params, + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('falls back to goBack when outcome loads without a winning code and no fallback route is provided', () => { render( = ({ @@ -63,12 +71,13 @@ const CampaignWinningView: React.FC = ({ resultDisplay = null, isRankLoading = false, isResultLoading = false, + fallbackRoute, }) => { const tw = useTailwind(); const { height: windowHeight } = useWindowDimensions(); const heroHeight = windowHeight * HERO_HEIGHT_RATIO; const insets = useSafeAreaInsets(); - const navigation = useNavigation(); + const navigation = useNavigation>(); const { trackEvent, createEventBuilder } = useAnalytics(); useTrackRewardsPageView({ @@ -78,9 +87,13 @@ const CampaignWinningView: React.FC = ({ useEffect(() => { if (!isLoading && hasOutcomeLoaded && winningCode === null) { + if (fallbackRoute) { + navigation.navigate(fallbackRoute.route, fallbackRoute.params); + return; + } navigation.goBack(); } - }, [isLoading, hasOutcomeLoaded, winningCode, navigation]); + }, [isLoading, hasOutcomeLoaded, winningCode, fallbackRoute, navigation]); const onDismiss = useCallback(() => { navigation.goBack(); diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx index 0a84edb2cbd..d1d1915730b 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx @@ -445,7 +445,9 @@ describe('OndoCampaignStatsView', () => { hasError: false, }); const { getByText } = render(); - const title = getByText('rewards.ondo_outcome_banner.winner_pending.title'); + const title = getByText( + 'rewards.campaign_outcome_banner.winner_pending.title', + ); fireEvent.press(title); expect(mockNavigate).toHaveBeenCalledWith( Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, @@ -475,7 +477,7 @@ describe('OndoCampaignStatsView', () => { }); const { queryByText } = render(); expect( - queryByText('rewards.ondo_outcome_banner.winner_pending.title'), + queryByText('rewards.campaign_outcome_banner.winner_pending.title'), ).toBeNull(); }); @@ -1024,7 +1026,9 @@ describe('OndoCampaignStatsView', () => { hasError: false, }); const { getByText } = render(); - const title = getByText('rewards.ondo_outcome_banner.winner_pending.title'); + const title = getByText( + 'rewards.campaign_outcome_banner.winner_pending.title', + ); fireEvent.press(title); expect(mockNavigate).toHaveBeenCalledWith( Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, diff --git a/app/components/UI/Rewards/Views/OndoCampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignWinningView.test.tsx index 62ae320afea..71207e2a07e 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.test.tsx @@ -6,6 +6,7 @@ import OndoCampaignWinningView, { import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; jest.mock('./CampaignWinningView', () => { const ReactActual = jest.requireActual('react'); @@ -93,6 +94,10 @@ describe('OndoCampaignWinningView', () => { resultDisplay: null, isRankLoading: false, isResultLoading: false, + fallbackRoute: { + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: 'campaign-ondo-1' }, + }, }), {}, ); diff --git a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx index 04e868b3d34..7e40d530493 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx @@ -4,6 +4,7 @@ import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParti import { formatOrdinalRank, formatPercentChange } from '../utils/formatUtils'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; const PRIZE_EMAIL = 'ondocampaign@consensys.net'; @@ -41,6 +42,14 @@ const OndoCampaignWinningView: React.FC = () => { return formatPercentChange(position.rateOfReturn); }, [position]); + const fallbackRoute = useMemo( + () => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId }, + }), + [campaignId], + ); + return ( { resultDisplay={resultDisplay} isRankLoading={isOutcomeLoading} isResultLoading={positionLoading} + fallbackRoute={fallbackRoute} /> ); }; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx index d6e7bf41fc2..13fe1dc93b3 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx @@ -6,6 +6,7 @@ import PerpsTradingCampaignWinningView, { import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; jest.mock('./CampaignWinningView', () => { const ReactActual = jest.requireActual('react'); @@ -104,6 +105,10 @@ describe('PerpsTradingCampaignWinningView', () => { resultDisplay: '+$1,500.25', isRankLoading: false, isResultLoading: false, + fallbackRoute: { + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: 'campaign-perps-1' }, + }, }), {}, ); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx index 44f85307136..f2a95fbd700 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx @@ -4,6 +4,7 @@ import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTrad import { formatOrdinalRank, formatSignedUsd } from '../utils/formatUtils'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; const PRIZE_EMAIL = 'perpscampaign@consensys.net'; @@ -48,6 +49,14 @@ const PerpsTradingCampaignWinningView: React.FC = () => { return formatSignedUsd(position.pnl); }, [position]); + const fallbackRoute = useMemo( + () => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId }, + }), + [campaignId], + ); + return ( { resultDisplay={resultDisplay} isRankLoading={isOutcomeLoading} isResultLoading={positionLoading} + fallbackRoute={fallbackRoute} /> ); }; From 8ba67c62e46cfcacef62225f55f5b34a1a79c27f Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Tue, 5 May 2026 23:32:24 -0400 Subject: [PATCH 09/10] Update PerpsTradingCampaignStatsView.test.tsx --- .../PerpsTradingCampaignStatsView.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx index 179886d186f..2b59bd18d1a 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx @@ -6,6 +6,7 @@ import PerpsTradingCampaignStatsView, { } from './PerpsTradingCampaignStatsView'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import { CampaignType, type PerpsTradingCampaignLeaderboardPositionDto, @@ -142,6 +143,13 @@ jest.mock('../components/RewardsErrorBanner', () => { jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); jest.mock('../hooks/useGetCampaignParticipantStatus'); +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(() => ({ + outcome: null, + isLoading: false, + hasError: false, + })), +})); jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -156,6 +164,10 @@ const mockUseGetParticipant = useGetCampaignParticipantStatus as jest.MockedFunction< typeof useGetCampaignParticipantStatus >; +const mockUsePerpsTradingCampaignParticipantOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { rank: 4, @@ -197,6 +209,11 @@ describe('PerpsTradingCampaignStatsView', () => { hasError: false, refetch: jest.fn(), }); + mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); mockUseGetPosition.mockReturnValue({ position: basePosition, isLoading: false, From 19cb7c61828eddc77b5980e2151db636c7172eae Mon Sep 17 00:00:00 2001 From: Rik Van Gulck Date: Wed, 6 May 2026 11:21:22 +0200 Subject: [PATCH 10/10] chore: edge case states for perps trading --- .../UI/Rewards/Views/CampaignWinningView.tsx | 95 +++--- .../PerpsTradingCampaignDetailsView.test.tsx | 136 ++++++++- .../Views/PerpsTradingCampaignDetailsView.tsx | 55 +++- .../PerpsTradingCampaignLeaderboardView.tsx | 1 + .../PerpsTradingCampaignStatsView.test.tsx | 24 ++ .../Views/PerpsTradingCampaignStatsView.tsx | 35 ++- .../PerpsCampaignStatsSummary.test.tsx | 14 + .../Campaigns/PerpsCampaignStatsSummary.tsx | 26 +- .../PerpsTradingCampaignEndedStats.test.tsx | 276 ++++++++++++++++++ .../PerpsTradingCampaignEndedStats.tsx | 153 ++++++++++ .../PerpsTradingCampaignStatsHeader.test.tsx | 10 + .../PerpsTradingCampaignStatsHeader.tsx | 5 +- locales/languages/en.json | 6 +- 13 files changed, 743 insertions(+), 93 deletions(-) create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.tsx index 712584de3c7..8eb65c54a01 100644 --- a/app/components/UI/Rewards/Views/CampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/CampaignWinningView.tsx @@ -173,56 +173,61 @@ const CampaignWinningView: React.FC = ({ flexDirection={BoxFlexDirection.Column} twClassName="w-full flex-1 gap-2 items-center px-4" > - - {strings('rewards.campaign_winning.you_won')} - - - {rankDisplay !== null ? ( - - {rankDisplay} - - ) : isRankLoading ? ( - - ) : null} + + {strings('rewards.campaign_winning.you_won')} + - {resultDisplay !== null ? ( - - {resultDisplay} - - ) : isResultLoading ? ( - - ) : null} - + + {rankDisplay !== null ? ( + + {rankDisplay} + + ) : isRankLoading ? ( + + ) : null} - - {strings('rewards.campaign_winning.email_instructions', { - email: prizeEmail, - })} - + {resultDisplay !== null ? ( + + {resultDisplay} + + ) : isResultLoading ? ( + + ) : null} + + + + {strings('rewards.campaign_winning.email_instructions', { + email: prizeEmail, + })} + + { }; }); +jest.mock('../components/Campaigns/CampaignOutcomeBanners', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + CampaignOutcomeBanner: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus: string; + winnerVerificationCode: string | null | undefined; + onWinnerPress: () => void; + }) => + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + }; +}); + jest.mock('../components/Campaigns/PerpsCampaignStatsSummary', () => { const ReactActual = jest.requireActual('react'); const { Pressable, Text, View } = jest.requireActual('react-native'); @@ -182,6 +207,18 @@ jest.mock('../components/Campaigns/PerpsTradingCampaignPrizePool', () => { }; }); +jest.mock('../components/Campaigns/PerpsTradingCampaignEndedStats', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'perps-campaign-ended-stats', + }), + }; +}); + jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); @@ -347,6 +384,7 @@ function setupHooks( hasCampaignsError?: boolean; participant?: { optedIn: boolean }; position?: { rank: number; neighbors: unknown[] } | null; + isPositionLoading?: boolean; totalParticipants?: number; outcome?: PerpsTradingCampaignParticipantOutcomeDto | null; } = {}, @@ -357,6 +395,7 @@ function setupHooks( hasCampaignsError = false, participant = { optedIn: false }, position = null, + isPositionLoading = false, totalParticipants: totalParticipantsOverride, outcome = null, } = overrides; @@ -394,7 +433,7 @@ function setupHooks( mockUseGetPerpsTradingCampaignLeaderboardPosition.mockReturnValue({ position: toMockLeaderboardPosition(position), - isLoading: false, + isLoading: isPositionLoading, hasError: false, hasFetched: true, refetch: jest.fn(), @@ -474,10 +513,10 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); }); - it('renders header, campaign status, prize pool, leaderboard, and CTA for active campaign', () => { - const { getByTestId, getByText } = render( - , - ); + it('renders header, campaign status, prize pool, leaderboard, and CTA for active opted-in campaign', () => { + setupHooks({ participant: { optedIn: true } }); + + const { getByTestId } = render(); expect( getByTestId(PERPS_CAMPAIGN_DETAILS_TEST_IDS.CONTAINER), @@ -489,7 +528,12 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(getByTestId('perps-trading-cta')).toBeDefined(); }); - it('hides How it works when the user has a leaderboard position', () => { + it('shows the prize pool section for active non-opted-in users', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + }); + + it('hides How it works when the user is opted in and has a leaderboard position', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -510,7 +554,50 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(queryByTestId('campaign-how-it-works')).toBeNull(); }); - it('shows How it works when active, user has no leaderboard position, and details include howItWorks', () => { + it('shows How it works when opted in, no position, and position not loading', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: null, + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('hides How it works while the leaderboard position is still loading for an opted-in user', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: null, + isPositionLoading: true, + }); + + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + + it('shows How it works when active, user is not opted in, and details include howItWorks', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -572,7 +659,7 @@ describe('PerpsTradingCampaignDetailsView', () => { ); }); - it('complete campaign shows leaderboard without stats row and hides CTA', () => { + it('complete campaign for non-opted-in user shows leaderboard, prize pool, and ended stats and hides CTA', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -588,10 +675,32 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(getByTestId('perps-leaderboard')).toBeDefined(); expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); - expect(queryByTestId('perps-prize-pool')).toBeNull(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); expect(queryByTestId('perps-trading-cta')).toBeNull(); }); + it('complete campaign for opted-in user (no leaderboard position) shows ended stats and prize pool', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: null, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + }); + it('shows outcome banner for completed opted-in participants and navigates winners to winning view', () => { setupHooks({ campaigns: [ @@ -683,7 +792,7 @@ describe('PerpsTradingCampaignDetailsView', () => { ); }); - it('does not show outcome banner outside the stats summary section', () => { + it('shows outcome banner inside the ended stats section for opted-in users with no leaderboard position', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -700,10 +809,13 @@ describe('PerpsTradingCampaignDetailsView', () => { }, }); - const { queryByTestId } = render(); + const { getByTestId, queryByTestId } = render( + , + ); expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); - expect(queryByTestId('campaign-outcome-banner-finalized-null')).toBeNull(); + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(getByTestId('campaign-outcome-banner-finalized-null')).toBeDefined(); }); it('displays total participant count when the leaderboard reports participants', () => { diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx index 7863d37119a..14032c9d1db 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx @@ -33,6 +33,8 @@ import PerpsTradingCampaignLeaderboard, { import PerpsTradingCampaignPrizePool from '../components/Campaigns/PerpsTradingCampaignPrizePool'; import PerpsTradingCampaignCTA from '../components/Campaigns/PerpsTradingCampaignCTA'; import PerpsCampaignStatsSummary from '../components/Campaigns/PerpsCampaignStatsSummary'; +import PerpsTradingCampaignEndedStats from '../components/Campaigns/PerpsTradingCampaignEndedStats'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; @@ -114,9 +116,10 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { refetch: refetchLeaderboard, } = useGetPerpsTradingCampaignLeaderboard(effectiveCampaignId || undefined); - const { position } = useGetPerpsTradingCampaignLeaderboardPosition( - isOptedIn ? effectiveCampaignId || undefined : undefined, - ); + const { position, isLoading: isPositionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? effectiveCampaignId || undefined : undefined, + ); const { outcome: participantOutcome } = usePerpsTradingCampaignParticipantOutcome( isComplete && isOptedIn ? effectiveCampaignId || undefined : undefined, @@ -145,6 +148,7 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { showStatsSummarySection, showPrizePoolSection, showLeaderboardSection, + showCampaignEndedStats, } = useMemo(() => { if (!campaign) { return { @@ -152,17 +156,32 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { showStatsSummarySection: false, showPrizePoolSection: false, showLeaderboardSection: false, + showCampaignEndedStats: false, }; } + const showEndedStats = + isComplete && !isParticipantStatusLoading && (!isOptedIn || !hasPosition); + return { showHowItWorksSection: - Boolean(campaign.details?.howItWorks) && isActive && !hasPosition, + Boolean(campaign.details?.howItWorks) && + isActive && + (!isOptedIn || (!hasPosition && !isPositionLoading)), showStatsSummarySection: hasPosition, - showPrizePoolSection: isActive, + showPrizePoolSection: isActive || isComplete, showLeaderboardSection: true, + showCampaignEndedStats: showEndedStats, }; - }, [campaign, isActive, hasPosition]); + }, [ + campaign, + isActive, + isComplete, + isOptedIn, + isParticipantStatusLoading, + hasPosition, + isPositionLoading, + ]); const navigateToLeaderboard = useCallback(() => { if (!effectiveCampaignId) return; @@ -278,6 +297,30 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { )} + {showCampaignEndedStats && ( + + + {isOptedIn && participantOutcome?.outcomeStatus != null && ( + + )} + + )} + {showStatsSummarySection && ( diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx index a62780a64e2..b601329431d 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx @@ -113,6 +113,7 @@ const PerpsTradingCampaignLeaderboardView: React.FC = () => { diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx index 2b59bd18d1a..64fbed6e9ad 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx @@ -327,6 +327,30 @@ describe('PerpsTradingCampaignStatsView', () => { ).toBeNull(); }); + it('hides volume and margin StatCells when campaign is complete (only PnL remains)', () => { + const completeCampaign = { + ...mockCampaign, + endDate: '2020-01-01T00:00:00Z', + }; + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector({ + rewards: { campaigns: [completeCampaign] }, + }), + ); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_PNL), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME), + ).toBeNull(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN), + ).toBeNull(); + }); + it('hides qualification cards when campaign is complete and shows last-computed after performance when position exists', () => { const completeCampaign = { ...mockCampaign, diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx index e93d898040c..73df3b431f5 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx @@ -159,6 +159,7 @@ const PerpsTradingCampaignStatsView: React.FC = () => { isLoading={isLoading} showComputedAt={false} showPnl={false} + isCampaignComplete={isCampaignComplete} /> @@ -178,22 +179,24 @@ const PerpsTradingCampaignStatsView: React.FC = () => { - - : undefined} - testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} - /> - : undefined} - testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} - /> - + {!isCampaignComplete && ( + + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} + /> + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} + /> + + )} {showQualifiedCard && ( { expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); }); + it('hides volume and margin StatCells when campaign is complete (only rank and PnL remain)', () => { + const { queryByTestId, getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.RANK)).toBeDefined(); + expect(getByTestId(TEST_IDS.PNL)).toBeDefined(); + expect(queryByTestId(TEST_IDS.NOTIONAL_VOLUME)).toBeNull(); + expect(queryByTestId(TEST_IDS.MARGIN_DEPLOYED)).toBeNull(); + }); + it("hides You're qualified card when campaign is complete", () => { const { queryByTestId } = render( = ({ testID={PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PNL} /> - - - - + {!isCampaignComplete && ( + + + + + )} {showQualifiedCard && ( { + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: { title: string; onConfirm: () => void }) => + ReactActual.createElement( + RN.View, + { testID: 'rewards-error-banner' }, + ReactActual.createElement(RN.Text, null, props.title), + ReactActual.createElement(RN.TouchableOpacity, { + testID: 'rewards-error-banner-retry', + onPress: props.onConfirm, + }), + ), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + Skeleton: (props: Record) => + ReactActual.createElement(RN.View, { testID: 'skeleton', ...props }), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatCompactUsd: (value: number) => `$${(value / 1_000_000).toFixed(1)}M`, + formatSignedUsd: (value: number) => { + const sign = value >= 0 ? '+' : '-'; + const abs = Math.abs(value).toLocaleString(); + return `${sign}$${abs}`; + }, +})); + +const makeEntry = ( + rank: number, + pnl: number, + qualified = true, +): PerpsTradingCampaignLeaderboardEntry => ({ + rank, + referralCode: `T-${rank}`, + pnl, + qualified, +}); + +const makeLeaderboard = ( + entriesCount: number, + totalParticipants?: number, + topPnl = 50_000, +): PerpsTradingCampaignLeaderboardDto => { + const entries = Array.from({ length: entriesCount }, (_, i) => + makeEntry(i + 1, topPnl - i * 1000), + ); + return { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: totalParticipants ?? entriesCount, + entries, + }; +}; + +describe('PerpsTradingCampaignEndedStats', () => { + it('renders all four stat cells with correct values when leaderboard has 20+ entries', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.CONTAINER), + ).toBeTruthy(); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe((200).toLocaleString()); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('$27.5M'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('+$80,000'); + // Leaderboard has 25 entries (>= 20) → fixed 20 winners + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('20'); + }); + + it('shows dash for winners when leaderboard has fewer than 20 entries', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('shows dashes when leaderboard and volume are null', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('renders skeletons while data is loading', () => { + const { getAllByTestId } = render( + , + ); + + const skeletons = getAllByTestId('skeleton'); + expect(skeletons.length).toBeGreaterThanOrEqual(3); + }); + + it('handles a leaderboard with no entries (no top PnL)', () => { + const empty: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 0, + entries: [], + }; + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe('0'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('shows error banner when both sources fail and triggers both retries', () => { + const onRetryLeaderboard = jest.fn(); + const onRetryVolume = jest.fn(); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('rewards-error-banner')).toBeTruthy(); + fireEvent.press(getByTestId('rewards-error-banner-retry')); + expect(onRetryLeaderboard).toHaveBeenCalledTimes(1); + expect(onRetryVolume).toHaveBeenCalledTimes(1); + }); + + it('shows error banner when only leaderboard fails; volume still renders', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('rewards-error-banner')).toBeTruthy(); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('$27.5M'); + }); + + it('does not render error banner when there are no errors', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); + + it('renders negative top PnL with error color and a minus sign', () => { + const negativeTop: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 1, + entries: [makeEntry(1, -5_000)], + }; + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-$5,000'); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx new file mode 100644 index 00000000000..af3a1616288 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx @@ -0,0 +1,153 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxFlexDirection, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { StatCell } from './OndoCampaignStatsSummary'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { strings } from '../../../../../../locales/i18n'; +import { formatCompactUsd, formatSignedUsd } from '../../utils/formatUtils'; + +const PERPS_WINNERS_CAP = 20; + +export const PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS = { + CONTAINER: 'perps-campaign-ended-stats-container', + TOTAL_PARTICIPANTS: 'perps-campaign-ended-stats-total-participants', + TOTAL_VOLUME: 'perps-campaign-ended-stats-total-volume', + TOP_PNL: 'perps-campaign-ended-stats-top-pnl', + WINNERS: 'perps-campaign-ended-stats-winners', +} as const; + +interface PerpsTradingCampaignEndedStatsProps { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + totalNotionalVolume: string | null; + isLeaderboardLoading: boolean; + isVolumeLoading: boolean; + hasLeaderboardError?: boolean; + hasVolumeError?: boolean; + onRetryLeaderboard?: () => void; + onRetryVolume?: () => void; +} + +const PerpsTradingCampaignEndedStats: React.FC< + PerpsTradingCampaignEndedStatsProps +> = ({ + leaderboard, + totalNotionalVolume, + isLeaderboardLoading, + isVolumeLoading, + hasLeaderboardError, + hasVolumeError, + onRetryLeaderboard, + onRetryVolume, +}) => { + const stats = useMemo(() => { + if (!leaderboard) return null; + + const entries = leaderboard.entries ?? []; + const totalParticipants = leaderboard.totalParticipants; + const topPnl = + entries.length > 0 ? Math.max(...entries.map((e) => e.pnl)) : null; + const hasFullLeaderboard = entries.length >= PERPS_WINNERS_CAP; + return { totalParticipants, topPnl, hasFullLeaderboard }; + }, [leaderboard]); + + const isLeaderboardSkeletonVisible = isLeaderboardLoading && !leaderboard; + const isVolumeSkeletonVisible = isVolumeLoading && !totalNotionalVolume; + + const hasError = + (hasLeaderboardError && !leaderboard) || + (hasVolumeError && !totalNotionalVolume); + + const totalParticipantsValue = stats + ? stats.totalParticipants.toLocaleString() + : '-'; + + const totalVolumeValue = totalNotionalVolume + ? formatCompactUsd(parseFloat(totalNotionalVolume)) + : '-'; + + const topPnlValue = + stats?.topPnl != null ? formatSignedUsd(stats.topPnl) : '-'; + + const topPnlColor = + stats?.topPnl != null && stats.topPnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault; + + const winnersValue = stats?.hasFullLeaderboard + ? String(PERPS_WINNERS_CAP) + : '-'; + + return ( + + + {strings('rewards.perps_trading_campaign.stats_title')} + + {hasError && ( + { + onRetryLeaderboard?.(); + onRetryVolume?.(); + }} + confirmButtonLabel={strings( + 'rewards.perps_trading_campaign.stats_retry', + )} + /> + )} + + + + + + + + + + ); +}; + +export default PerpsTradingCampaignEndedStats; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx index d103b8e8c58..2894eaf9e28 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx @@ -104,6 +104,16 @@ describe('PerpsTradingCampaignStatsHeader', () => { expect(queryByTestId(TEST_IDS.QUALIFIED_ICON)).toBeNull(); }); + it('hides the pending tag when the campaign is complete', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + it('shows em dashes for rank and PnL when position is null', () => { const { getByTestId } = render( , diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx index 63c4bdee9a7..31d3623c69b 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx @@ -38,6 +38,8 @@ interface PerpsTradingCampaignStatsHeaderProps { showPnl?: boolean; /** When true, shows formatted `computedAt` time on the same row as PnL, right-aligned in alternative text color. */ showComputedAt?: boolean; + /** When true, suppresses the "Pending" tag — qualification is locked once the campaign ends. */ + isCampaignComplete?: boolean; } const PerpsTradingCampaignStatsHeader: React.FC< @@ -47,6 +49,7 @@ const PerpsTradingCampaignStatsHeader: React.FC< isLoading = false, showPnl = true, showComputedAt = true, + isCampaignComplete = false, }) => { const tw = useTailwind(); @@ -80,7 +83,7 @@ const PerpsTradingCampaignStatsHeader: React.FC< {strings('rewards.perps_trading_campaign.label_your_rank')} - {isPending && ( + {isPending && !isCampaignComplete && ( )} {isQualified && ( diff --git a/locales/languages/en.json b/locales/languages/en.json index f1f456c5309..653d0846a64 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -8641,7 +8641,11 @@ "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", "stats_error_title": "Unable to load stats", "stats_error_description": "We had a problem loading your stats. Please try again.", - "stats_retry": "Retry" + "stats_retry": "Retry", + "completed_label_total_participants": "Total participants", + "completed_label_total_volume": "Total volume", + "completed_label_top_pnl": "Top PnL", + "completed_label_winners": "Winners" }, "campaigns_preview": { "title": "Campaigns",