diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx
index e903bafb8ec..b824e326b1c 100644
--- a/app/components/UI/Rewards/RewardsNavigator.test.tsx
+++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx
@@ -282,6 +282,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..77ab123834e 100644
--- a/app/components/UI/Rewards/RewardsNavigator.tsx
+++ b/app/components/UI/Rewards/RewardsNavigator.tsx
@@ -37,9 +37,9 @@ import { useReferralDetails } from './hooks/useReferralDetails';
import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge';
import useRewardsToast from './hooks/useRewardsToast';
import { strings } from '../../../../locales/i18n';
+import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView';
let sessionNotificationsNudgeShown = false;
-
const Stack = createStackNavigator();
const RewardsNavigator: React.FC = () => {
@@ -296,6 +296,11 @@ const RewardsNavigator: React.FC = () => {
component={PerpsTradingCampaignStatsView}
options={{ headerShown: false }}
/>
+
>
) : null}
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..afbc489f176
--- /dev/null
+++ b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx
@@ -0,0 +1,297 @@
+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();
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }),
+}));
+
+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?: {
+ code?: string;
+ campaignName?: string;
+ email?: string;
+ },
+ ) => {
+ 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,
+ rankDisplay: 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 rank and result display when provided', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('3rd')).toBeTruthy();
+ expect(getByText('+12.34%')).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('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(
+ ,
+ );
+ 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..8eb65c54a01
--- /dev/null
+++ b/app/components/UI/Rewards/Views/CampaignWinningView.tsx
@@ -0,0 +1,267 @@
+import React, { useCallback, useEffect } from 'react';
+import { Image, Linking, ScrollView, useWindowDimensions } from 'react-native';
+import Clipboard from '@react-native-clipboard/clipboard';
+import {
+ useNavigation,
+ type NavigationProp,
+ type ParamListBase,
+} 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,
+ Skeleton,
+ Text,
+ TextColor,
+ TextVariant,
+ FontWeight,
+} 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;
+ rankDisplay: string | null;
+ resultDisplay?: string | null;
+ isRankLoading?: boolean;
+ isResultLoading?: boolean;
+ fallbackRoute?: {
+ route: string;
+ params?: object;
+ };
+}
+
+const CampaignWinningView: React.FC = ({
+ testID,
+ viewName,
+ prizeEmail,
+ campaignName,
+ campaignId,
+ analyticsPageType,
+ winningCode,
+ hasOutcomeLoaded,
+ isLoading,
+ rankDisplay,
+ 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 { trackEvent, createEventBuilder } = useAnalytics();
+
+ useTrackRewardsPageView({
+ page_type: analyticsPageType,
+ campaign_id: campaignId,
+ });
+
+ useEffect(() => {
+ if (!isLoading && hasOutcomeLoaded && winningCode === null) {
+ if (fallbackRoute) {
+ navigation.navigate(fallbackRoute.route, fallbackRoute.params);
+ return;
+ }
+ navigation.goBack();
+ }
+ }, [isLoading, hasOutcomeLoaded, winningCode, fallbackRoute, 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')}
+
+
+
+ {rankDisplay !== null ? (
+
+ {rankDisplay}
+
+ ) : isRankLoading ? (
+
+ ) : null}
+
+ {resultDisplay !== null ? (
+
+ {resultDisplay}
+
+ ) : isResultLoading ? (
+
+ ) : null}
+
+
+
+ {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..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();
@@ -30,11 +32,18 @@ const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction<
jest.mock('../hooks/useOndoOutcomeToast', () => ({
useOndoOutcomeToast: jest.fn(),
}));
-import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
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');
@@ -173,7 +182,6 @@ describe('CampaignsView', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseRewardCampaigns.mockReturnValue(hookDefaults);
- mockUseOndoOutcomeToast.mockReturnValue(undefined);
});
it('renders the header with the correct title', () => {
@@ -185,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();
@@ -374,11 +391,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..14e5324ba17 100644
--- a/app/components/UI/Rewards/Views/CampaignsView.tsx
+++ b/app/components/UI/Rewards/Views/CampaignsView.tsx
@@ -16,11 +16,12 @@ 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';
import { strings } from '../../../../../locales/i18n';
+import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
+import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast';
/**
* CampaignsView displays all campaigns organized by status:
@@ -31,9 +32,10 @@ import { strings } from '../../../../../locales/i18n';
const CampaignsView: React.FC = () => {
const tw = useTailwind();
const navigation = useNavigation();
- useOndoOutcomeToast();
const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } =
useRewardCampaigns();
+ useOndoOutcomeToast();
+ usePerpsTradingCampaignEndedOutcomeToast();
useTrackRewardsPageView({ page_type: 'campaigns_overview' });
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/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
- >;
-
-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 }: { testID: string }) =>
+ ReactActual.createElement(View, { testID }),
+ ),
};
});
-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,
- },
- 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,
+ 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: 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();
- });
+ rankDisplay: null,
+ resultDisplay: null,
+ isRankLoading: false,
+ isResultLoading: false,
+ fallbackRoute: {
+ route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: 'campaign-ondo-1' },
+ },
+ }),
+ {},
+ );
});
- 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({
+ it('passes rank and result display when position is available', () => {
+ mockUseOutcome.mockReturnValue({
outcome: {
subscriptionId: 'sub-1',
outcomeStatus: 'pending',
- winnerVerificationCode: null,
+ winnerVerificationCode: 'ONDO-WIN-99',
+ tierRank: 3,
},
isLoading: false,
hasError: false,
});
- const setStringSpy = jest.spyOn(Clipboard, 'setString');
- const { getByTestId } = render();
- fireEvent.press(getByTestId('copyable-trigger'));
- expect(setStringSpy).not.toHaveBeenCalled();
+ mockUsePosition.mockReturnValue({
+ position: { rank: 9, rateOfReturn: 0.1234 } as never,
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
+
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ 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 59b3434babe..7e40d530493 100644
--- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx
+++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx
@@ -1,45 +1,13 @@
-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 { 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 React, { useMemo } from 'react';
+import { useRoute, RouteProp } from '@react-navigation/native';
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';
+import Routes from '../../../../constants/navigation/Routes';
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 = {
@@ -51,15 +19,11 @@ export const ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS = {
} as const;
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,186 +32,41 @@ 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', {
- 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]);
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- {strings('rewards.ondo_campaign_winning.you_won')}
-
-
- {(positionLoading || position) && (
-
- {rankDisplay !== null ? (
-
- {rankDisplay}
-
- ) : (
-
- )}
-
- {rateDisplay !== null ? (
-
- {rateDisplay}
-
- ) : (
-
- )}
-
- )}
-
-
- {strings('rewards.ondo_campaign_winning.email_instructions')}
-
-
-
-
-
-
-
-
-
+ const fallbackRoute = useMemo(
+ () => ({
+ route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId },
+ }),
+ [campaignId],
+ );
-
-
-
-
-
+ return (
+
);
};
diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx
index 54fe6b66bee..5ab6a0e70aa 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,
@@ -135,15 +139,61 @@ jest.mock('../components/Campaigns/CampaignHowItWorks', () => {
};
});
+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 { 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'),
+ ),
+ ),
};
});
@@ -157,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');
@@ -247,6 +309,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';
@@ -316,7 +384,9 @@ function setupHooks(
hasCampaignsError?: boolean;
participant?: { optedIn: boolean };
position?: { rank: number; neighbors: unknown[] } | null;
+ isPositionLoading?: boolean;
totalParticipants?: number;
+ outcome?: PerpsTradingCampaignParticipantOutcomeDto | null;
} = {},
) {
const {
@@ -325,7 +395,9 @@ function setupHooks(
hasCampaignsError = false,
participant = { optedIn: false },
position = null,
+ isPositionLoading = false,
totalParticipants: totalParticipantsOverride,
+ outcome = null,
} = overrides;
mockUseRewardCampaigns.mockReturnValue({
@@ -361,7 +433,7 @@ function setupHooks(
mockUseGetPerpsTradingCampaignLeaderboardPosition.mockReturnValue({
position: toMockLeaderboardPosition(position),
- isLoading: false,
+ isLoading: isPositionLoading,
hasError: false,
hasFetched: true,
refetch: jest.fn(),
@@ -370,6 +442,12 @@ function setupHooks(
mockUseGetPerpsTradingCampaignVolume.mockReturnValue({
...defaultVolumeHook,
} as ReturnType);
+
+ mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({
+ outcome,
+ isLoading: false,
+ hasError: false,
+ } as ReturnType);
}
jest.mock('../../../../../locales/i18n', () => ({
@@ -398,6 +476,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) {
@@ -434,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),
@@ -449,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({
@@ -470,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({
@@ -532,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({
@@ -548,10 +675,149 @@ 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: [
+ 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('shows outcome banner inside the ended stats section for opted-in users with no leaderboard position', () => {
+ 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 { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('perps-campaign-stats-summary-container')).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', () => {
setupHooks({ totalParticipants: 1500 });
diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx
index 0310b69d8ea..14032c9d1db 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,
@@ -28,11 +33,14 @@ 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';
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 +61,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();
@@ -103,9 +116,14 @@ 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,
+ );
const {
volume,
@@ -130,6 +148,7 @@ const PerpsTradingCampaignDetailsView: React.FC = () => {
showStatsSummarySection,
showPrizePoolSection,
showLeaderboardSection,
+ showCampaignEndedStats,
} = useMemo(() => {
if (!campaign) {
return {
@@ -137,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;
@@ -163,6 +197,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, {
@@ -233,6 +297,30 @@ const PerpsTradingCampaignDetailsView: React.FC = () => {
)}
+ {showCampaignEndedStats && (
+
+
+ {isOptedIn && participantOutcome?.outcomeStatus != null && (
+
+ )}
+
+ )}
+
{showStatsSummarySection && (
@@ -258,6 +346,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/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 179886d186f..64fbed6e9ad 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,
@@ -310,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 8e6213ed14d..73df3b431f5 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 (
{
isLoading={isLoading}
showComputedAt={false}
showPnl={false}
+ isCampaignComplete={isCampaignComplete}
/>
@@ -164,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 && (
{
)}
+ {/* ── 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 }: { testID: string }) =>
+ ReactActual.createElement(View, { testID }),
+ ),
+ };
+});
+
+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: () => ({
+ 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 };
+});
+
+const mockUseOutcome =
+ usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction<
+ typeof usePerpsTradingCampaignParticipantOutcome
+ >;
+const mockUsePosition =
+ useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction<
+ typeof useGetPerpsTradingCampaignLeaderboardPosition
+ >;
+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,
+ });
+ 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', () => {
+ 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,
+ rankDisplay: '3rd',
+ resultDisplay: '+$1,500.25',
+ isRankLoading: false,
+ isResultLoading: false,
+ fallbackRoute: {
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: 'campaign-perps-1' },
+ },
+ }),
+ {},
+ );
+ });
+
+ 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('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();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rankDisplay: '3rd',
+ resultDisplay: null,
+ isRankLoading: false,
+ isResultLoading: false,
+ }),
+ {},
+ );
+ });
+
+ it('does not pass rankDisplay when outcome has no rank', () => {
+ mockUseOutcome.mockReturnValue({
+ outcome: {
+ subscriptionId: 'sub-1',
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'CODE',
+ rank: null,
+ },
+ isLoading: false,
+ hasError: false,
+ });
+ render();
+ 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
new file mode 100644
index 00000000000..f2a95fbd700
--- /dev/null
+++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx
@@ -0,0 +1,80 @@
+import React, { useMemo } from 'react';
+import { useRoute, RouteProp } from '@react-navigation/native';
+import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome';
+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';
+
+// 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 route =
+ useRoute<
+ RouteProp<
+ PerpsTradingCampaignWinningRouteParams,
+ 'RewardsPerpsTradingCampaignWinning'
+ >
+ >();
+ const { campaignId, campaignName } = route.params;
+
+ const { outcome, isLoading: isOutcomeLoading } =
+ usePerpsTradingCampaignParticipantOutcome(campaignId);
+ const winningCode = outcome?.winnerVerificationCode ?? null;
+
+ const { position, isLoading: positionLoading } =
+ useGetPerpsTradingCampaignLeaderboardPosition(campaignId);
+
+ const rankDisplay = useMemo(() => {
+ if (!outcome?.rank) {
+ return null;
+ }
+ return formatOrdinalRank(outcome.rank);
+ }, [outcome]);
+
+ const resultDisplay = useMemo(() => {
+ if (!position) return null;
+ return formatSignedUsd(position.pnl);
+ }, [position]);
+
+ const fallbackRoute = useMemo(
+ () => ({
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId },
+ }),
+ [campaignId],
+ );
+
+ 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..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', () => ({
@@ -170,6 +172,10 @@ 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';
@@ -186,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();
@@ -320,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 ded32bc79da..3613bd3a6d5 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';
@@ -30,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();
@@ -41,6 +42,8 @@ const RewardsDashboard: React.FC = () => {
useTrackRewardsPageView({ page_type: 'home' });
useOndoOutcomeToast();
+ usePerpsTradingCampaignEndedOutcomeToast();
+
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 && (
- ({
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 = {
@@ -160,6 +184,20 @@ describe('PerpsCampaignStatsSummary', () => {
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(
{
);
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..7c3c7a2fe49 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;
@@ -125,18 +133,20 @@ const PerpsCampaignStatsSummary: React.FC = ({
testID={PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PNL}
/>
-
-
-
-
+ {!isCampaignComplete && (
+
+
+
+
+ )}
{showQualifiedCard && (
= ({
)}
+
+ {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && (
+
+ )}
);
};
diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx
new file mode 100644
index 00000000000..f3cb469d9ea
--- /dev/null
+++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx
@@ -0,0 +1,276 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import PerpsTradingCampaignEndedStats, {
+ PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS,
+} from './PerpsTradingCampaignEndedStats';
+import type {
+ PerpsTradingCampaignLeaderboardDto,
+ PerpsTradingCampaignLeaderboardEntry,
+} from '../../../../../core/Engine/controllers/rewards-controller/types';
+
+jest.mock('../RewardsErrorBanner', () => {
+ 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/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}
+
+ )}
({
+ 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..012cbe4549a
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts
@@ -0,0 +1,173 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useSelector } from 'react-redux';
+import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
+import { selectCampaignParticipantOptedIn } 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', () => ({
+ selectCampaignParticipantOptedIn: jest.fn(),
+}));
+
+const mockCall = Engine.controllerMessenger.call as jest.MockedFunction<
+ typeof Engine.controllerMessenger.call
+>;
+const mockUseSelector = useSelector as jest.MockedFunction;
+const mockSelectCampaignParticipantOptedIn =
+ selectCampaignParticipantOptedIn as jest.MockedFunction<
+ typeof selectCampaignParticipantOptedIn
+ >;
+
+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 participantOptedInSelector = jest.fn().mockReturnValue(isOptedIn);
+ mockSelectCampaignParticipantOptedIn.mockReturnValue(
+ participantOptedInSelector,
+ );
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectRewardsSubscriptionId) return subscriptionId;
+ if (selector === participantOptedInSelector) return isOptedIn;
+ 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..1f10639c665
--- /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 { selectCampaignParticipantOptedIn } 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(
+ selectCampaignParticipantOptedIn(subscriptionId, campaignId),
+ );
+ 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/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/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/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/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';
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/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/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts
new file mode 100644
index 00000000000..44c7d775a5c
--- /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 Perps details view route', () => {
+ renderHook(() => usePerpsTradingCampaignEndedOutcomeToast());
+ const { getNonWinnerNavigation } =
+ mockUseCampaignOutcomeToast.mock.calls[0][0];
+ const nav = getNonWinnerNavigation(makeCampaign());
+ expect(nav).toEqual({
+ 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
new file mode 100644
index 00000000000..ae99229b1ba
--- /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: (campaign) => ({
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: campaign.id },
+ }),
+ });
+}
+
+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..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({
@@ -3829,8 +3863,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 +3875,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 +3887,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/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 => {
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 a222b35ae3b..7ef2b7ed478 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -8645,7 +8645,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",
@@ -8671,29 +8675,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": {
@@ -8701,15 +8705,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": {