Skip to content

Commit 08675af

Browse files
VGR-GITsophieqgu
andauthored
feat(rewards): add Perps Trading campaign participant outcome (#29648)
## **Description** Adds Perps Trading campaign outcome support to the Rewards stack, mirroring the Ondo GM outcome pattern. After a Perps Trading campaign ends, opted-in participants can query `GET /perps-trading/:campaignId/outcome/me` to learn whether they won. This PR wires that endpoint into the mobile app end-to-end: - **Winners** (have a `winnerVerificationCode` + `pending` status) see a toast → tap → `PerpsTradingCampaignWinningView` showing their rank, verification code (copy or email to claim prize) - **Non-winners once results are final** (`finalized` status, no code) see a toast → tap → campaigns view ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: rwds-perps-trading-outcome ## **Changes** | Layer | File | What | |---|---|---| | Types | `types.ts` | `CampaignType.PERPS_TRADING`, `PerpsTradingCampaignParticipantOutcomeDto` | | Data service | `rewards-data-service.ts` | `getPerpsTradingCampaignParticipantOutcome()` → `GET /perps-trading/:campaignId/outcome/me` | | Controller | `RewardsController.ts` | Method with 10-min in-memory cache + auth retry | | Action types / Messenger | multiple | Registered in action union and allowed actions | | Hook | `usePerpsTradingCampaignParticipantOutcome.ts` | Fetches outcome via controller messenger | | Toast hook | `usePerpsTradingCampaignEndedOutcomeToast.ts` | `winner_pending` → winning view; `participant_finalized` → campaigns | | Screen | `PerpsTradingCampaignWinningView.tsx` | Rank + verification code, copy + mailto | | Utility | `formatUtils.ts` | `formatOrdinalRank()` (1st/2nd/3rd…) | | Routes / Navigator | multiple | New route constant, screen registered, toast hook wired | | Strings | `en.json` | Toast + winning view copy | ## **Manual testing steps** ```gherkin Feature: Perps Trading campaign outcome Scenario: winner sees outcome toast and winning view Given a Perps Trading campaign that has ended And the current user is opted in and has a winner row with status "pending" and a verification code When the user opens the Rewards section Then a toast appears: "You won! 🏆" with "View details" link When the user taps "View details" Then the PerpsTradingCampaignWinningView opens showing their rank and verification code And the user can copy the code or tap "Open mail" to email perpscampaign@consensys.net Scenario: non-winner sees finalized toast Given a Perps Trading campaign that has ended with 20 finalized winners And the current user is opted in but has no winner row When the user opens the Rewards section Then a toast appears: "The results are in" with "View" link When the user taps "View" Then the user is navigated to the campaigns view ``` ## **Screenshots/Recordings** ### **Before** No Perps Trading outcome UI. ### **After** <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-05-05 at 19 33 02" src="https://github.com/user-attachments/assets/c2d21dda-6db6-4260-b0b3-480cbea38f18" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-05-05 at 20 50 09" src="https://github.com/user-attachments/assets/aae8fafb-2bac-4349-83d7-ba00182fa498" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-05-05 at 20 53 05" src="https://github.com/user-attachments/assets/425fc361-d51f-4d54-a8e6-39f3b34fe1c3" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-05-05 at 20 53 10" src="https://github.com/user-attachments/assets/c96b8fb0-c19d-4b85-a1a4-81af881aa612" /> _(to be added — requires a completed Perps Trading campaign with backend data)_ ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new post-campaign outcome UI/UX (toasts, auto-navigation, new routes/screens) that can affect user navigation and state gating across Rewards; issues would mainly surface as incorrect redirects or missing banners/toasts. > > **Overview** > Adds a shared `CampaignWinningView` and wires both Ondo and Perps campaigns to use it for winner verification-code display (copy + mailto) with a consistent fallback when no code is available. > > Introduces a generic `useCampaignParticipantOutcome` + `useCampaignOutcomeToast` pattern, then adds Perps Trading support via `usePerpsTradingCampaignParticipantOutcome` and `usePerpsTradingCampaignEndedOutcomeToast`, mounting these toasts on `RewardsDashboard` and `CampaignsView`. > > Expands Perps Trading end-of-campaign UX: new `PerpsTradingCampaignWinningView` route/screen, outcome banners on details/stats (with one-time session auto-navigation for pending winners), an ended-campaign stats panel, and tweaks to completed-campaign stat presentation (hide pending tag; hide volume/margin once complete). Also consolidates outcome banner locale keys and generalizes `CampaignOutcomeBanner` usage across campaigns. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 464bd29. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: sophieqgu <sophieqgu@gmail.com>
1 parent 5011f84 commit 08675af

85 files changed

Lines changed: 4352 additions & 1940 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/components/UI/Rewards/RewardsNavigator.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,8 @@ jest.mock('./hooks/useRewardsToast', () => ({
282282
loading: jest.fn(),
283283
entriesClosed: jest.fn(),
284284
enableNotificationsNudge: jest.fn(),
285+
outcomeWinner: jest.fn(),
286+
outcomeNonWinner: jest.fn(),
285287
},
286288
})),
287289
}));

app/components/UI/Rewards/RewardsNavigator.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ import { useReferralDetails } from './hooks/useReferralDetails';
3737
import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge';
3838
import useRewardsToast from './hooks/useRewardsToast';
3939
import { strings } from '../../../../locales/i18n';
40+
import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView';
4041

4142
let sessionNotificationsNudgeShown = false;
42-
4343
const Stack = createStackNavigator();
4444

4545
const RewardsNavigator: React.FC = () => {
@@ -296,6 +296,11 @@ const RewardsNavigator: React.FC = () => {
296296
component={PerpsTradingCampaignStatsView}
297297
options={{ headerShown: false }}
298298
/>
299+
<Stack.Screen
300+
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW}
301+
component={PerpsTradingCampaignWinningView}
302+
options={{ headerShown: false }}
303+
/>
299304
</>
300305
) : null}
301306
</Stack.Navigator>
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import React from 'react';
2+
import { render, fireEvent } from '@testing-library/react-native';
3+
import { Linking } from 'react-native';
4+
import Clipboard from '@react-native-clipboard/clipboard';
5+
import CampaignWinningView, {
6+
CampaignWinningViewProps,
7+
} from './CampaignWinningView';
8+
import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView';
9+
10+
jest.mock('../../../../images/rewards/campaign_winning.png', () => ({
11+
__esModule: true,
12+
default: 1,
13+
}));
14+
15+
const mockGoBack = jest.fn();
16+
const mockNavigate = jest.fn();
17+
18+
jest.mock('@react-navigation/native', () => ({
19+
useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }),
20+
}));
21+
22+
jest.mock('@metamask/design-system-twrnc-preset', () => {
23+
const tw = (...args: unknown[]) => args;
24+
tw.style = (...args: unknown[]) => args;
25+
return { useTailwind: () => tw };
26+
});
27+
28+
jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({
29+
default: () => ({ width: 390, height: 844 }),
30+
}));
31+
32+
jest.mock('react-native-safe-area-context', () => {
33+
const actual = jest.requireActual('react-native-safe-area-context');
34+
return {
35+
...actual,
36+
useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }),
37+
};
38+
});
39+
40+
jest.mock('../../../Views/ErrorBoundary', () => {
41+
const ReactActual = jest.requireActual('react');
42+
const { View } = jest.requireActual('react-native');
43+
return {
44+
__esModule: true,
45+
default: ({ children }: { children: React.ReactNode }) =>
46+
ReactActual.createElement(View, null, children),
47+
};
48+
});
49+
50+
jest.mock('../hooks/useTrackRewardsPageView', () => ({
51+
__esModule: true,
52+
default: jest.fn(),
53+
}));
54+
55+
jest.mock('../../../../core/Analytics', () => ({
56+
MetaMetricsEvents: {
57+
REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED',
58+
},
59+
}));
60+
61+
jest.mock('../utils', () => ({
62+
RewardsMetricsButtons: {
63+
COPY_WINNER_VERIFICATION_CODE: 'copy_winner_verification_code',
64+
},
65+
}));
66+
67+
const mockTrackEvent = jest.fn();
68+
const mockBuild = jest.fn(() => ({}));
69+
jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
70+
useAnalytics: () => ({
71+
trackEvent: mockTrackEvent,
72+
createEventBuilder: () => ({
73+
addProperties: () => ({ build: mockBuild }),
74+
}),
75+
}),
76+
}));
77+
78+
jest.mock('../components/ReferralDetails/CopyableField', () => {
79+
const ReactActual = jest.requireActual('react');
80+
const { View, Text, Pressable } = jest.requireActual('react-native');
81+
return {
82+
__esModule: true,
83+
default: ({
84+
label,
85+
value,
86+
onCopy,
87+
}: {
88+
label: string;
89+
value?: string | null;
90+
onCopy?: () => void;
91+
}) =>
92+
ReactActual.createElement(
93+
View,
94+
{ testID: 'copyable-field' },
95+
ReactActual.createElement(Text, null, label),
96+
ReactActual.createElement(
97+
Text,
98+
{ testID: 'copyable-value' },
99+
value ?? '',
100+
),
101+
ReactActual.createElement(Pressable, {
102+
testID: 'copyable-trigger',
103+
onPress: onCopy,
104+
}),
105+
),
106+
};
107+
});
108+
109+
jest.mock('../../../../../locales/i18n', () => ({
110+
strings: jest.fn(
111+
(
112+
key: string,
113+
params?: {
114+
code?: string;
115+
campaignName?: string;
116+
email?: string;
117+
},
118+
) => {
119+
if (
120+
key === 'rewards.campaign_winning.mail_subject' &&
121+
params?.campaignName
122+
)
123+
return `${params.campaignName} prize claim`;
124+
if (key === 'rewards.campaign_winning.mail_body' && params?.code)
125+
return `My winning code: ${params.code}`;
126+
if (
127+
key === 'rewards.campaign_winning.email_instructions' &&
128+
params?.email
129+
)
130+
return `Email ${params.email} with your code`;
131+
const map: Record<string, string> = {
132+
'rewards.campaign_winning.you_won': 'You won',
133+
'rewards.campaign_winning.open_mail': 'Open mail',
134+
'rewards.campaign_winning.skip_for_now': 'Skip for now',
135+
'rewards.campaign_winning.winning_code': 'Winning code',
136+
'rewards.campaign_winning.close_a11y': 'Close',
137+
};
138+
return map[key] ?? key;
139+
},
140+
),
141+
}));
142+
143+
const PRIZE_EMAIL = 'test@consensys.net';
144+
const CAMPAIGN_NAME = 'Test Campaign';
145+
const CAMPAIGN_ID = 'campaign-test-1';
146+
const WINNING_CODE = 'WIN-123';
147+
const mockUseTrackRewardsPageView =
148+
useTrackRewardsPageView as jest.MockedFunction<
149+
typeof useTrackRewardsPageView
150+
>;
151+
152+
const defaultProps: CampaignWinningViewProps = {
153+
testID: 'test-winning-view',
154+
viewName: 'TestWinningView',
155+
prizeEmail: PRIZE_EMAIL,
156+
campaignName: CAMPAIGN_NAME,
157+
campaignId: CAMPAIGN_ID,
158+
analyticsPageType: 'test_campaign_winning',
159+
winningCode: WINNING_CODE,
160+
hasOutcomeLoaded: true,
161+
isLoading: false,
162+
rankDisplay: null,
163+
};
164+
165+
describe('CampaignWinningView', () => {
166+
beforeEach(() => {
167+
jest.clearAllMocks();
168+
});
169+
170+
it('renders the main container with the provided testID', () => {
171+
const { getByTestId } = render(<CampaignWinningView {...defaultProps} />);
172+
expect(getByTestId('test-winning-view')).toBeTruthy();
173+
});
174+
175+
it('renders "You won" text', () => {
176+
const { getByText } = render(<CampaignWinningView {...defaultProps} />);
177+
expect(getByText('You won')).toBeTruthy();
178+
});
179+
180+
it('renders email instructions with the prizeEmail', () => {
181+
const { getByText } = render(<CampaignWinningView {...defaultProps} />);
182+
expect(getByText(`Email ${PRIZE_EMAIL} with your code`)).toBeTruthy();
183+
});
184+
185+
it('tracks page view with the campaign id', () => {
186+
render(<CampaignWinningView {...defaultProps} />);
187+
expect(mockUseTrackRewardsPageView).toHaveBeenCalledWith({
188+
page_type: 'test_campaign_winning',
189+
campaign_id: CAMPAIGN_ID,
190+
});
191+
});
192+
193+
it('renders rank and result display when provided', () => {
194+
const { getByText } = render(
195+
<CampaignWinningView
196+
{...defaultProps}
197+
rankDisplay="3rd"
198+
resultDisplay="+12.34%"
199+
/>,
200+
);
201+
expect(getByText('3rd')).toBeTruthy();
202+
expect(getByText('+12.34%')).toBeTruthy();
203+
});
204+
205+
it('calls goBack when Skip for now is pressed', () => {
206+
const { getByText } = render(<CampaignWinningView {...defaultProps} />);
207+
fireEvent.press(getByText('Skip for now'));
208+
expect(mockGoBack).toHaveBeenCalledTimes(1);
209+
});
210+
211+
it('calls goBack when Close button is pressed', () => {
212+
const { getByLabelText } = render(
213+
<CampaignWinningView {...defaultProps} />,
214+
);
215+
fireEvent.press(getByLabelText('Close'));
216+
expect(mockGoBack).toHaveBeenCalledTimes(1);
217+
});
218+
219+
it('copies winning code and fires analytics when copy is triggered', () => {
220+
const setStringSpy = jest.spyOn(Clipboard, 'setString');
221+
const { getByTestId } = render(<CampaignWinningView {...defaultProps} />);
222+
fireEvent.press(getByTestId('copyable-trigger'));
223+
expect(setStringSpy).toHaveBeenCalledWith(WINNING_CODE);
224+
expect(mockTrackEvent).toHaveBeenCalled();
225+
setStringSpy.mockRestore();
226+
});
227+
228+
it('opens mailto with the correct email and code when Open mail is pressed', async () => {
229+
const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
230+
const { getByText } = render(<CampaignWinningView {...defaultProps} />);
231+
fireEvent.press(getByText('Open mail'));
232+
expect(openSpy).toHaveBeenCalled();
233+
const url = openSpy.mock.calls[0][0] as string;
234+
expect(url).toContain(`mailto:${PRIZE_EMAIL}`);
235+
expect(url).toContain(encodeURIComponent(WINNING_CODE));
236+
openSpy.mockRestore();
237+
});
238+
239+
it('navigates to fallback route when outcome loads without a winning code', () => {
240+
const fallbackRoute = {
241+
route: 'CampaignDetails',
242+
params: { campaignId: CAMPAIGN_ID },
243+
};
244+
245+
render(
246+
<CampaignWinningView
247+
{...defaultProps}
248+
winningCode={null}
249+
hasOutcomeLoaded
250+
isLoading={false}
251+
fallbackRoute={fallbackRoute}
252+
/>,
253+
);
254+
255+
expect(mockNavigate).toHaveBeenCalledWith(
256+
fallbackRoute.route,
257+
fallbackRoute.params,
258+
);
259+
expect(mockGoBack).not.toHaveBeenCalled();
260+
});
261+
262+
it('falls back to goBack when outcome loads without a winning code and no fallback route is provided', () => {
263+
render(
264+
<CampaignWinningView
265+
{...defaultProps}
266+
winningCode={null}
267+
hasOutcomeLoaded
268+
isLoading={false}
269+
/>,
270+
);
271+
expect(mockGoBack).toHaveBeenCalledTimes(1);
272+
});
273+
274+
it('does not call goBack before outcome has loaded', () => {
275+
render(
276+
<CampaignWinningView
277+
{...defaultProps}
278+
winningCode={null}
279+
hasOutcomeLoaded={false}
280+
isLoading={false}
281+
/>,
282+
);
283+
expect(mockGoBack).not.toHaveBeenCalled();
284+
});
285+
286+
it('does not call goBack while still loading', () => {
287+
render(
288+
<CampaignWinningView
289+
{...defaultProps}
290+
winningCode={null}
291+
hasOutcomeLoaded
292+
isLoading
293+
/>,
294+
);
295+
expect(mockGoBack).not.toHaveBeenCalled();
296+
});
297+
});

0 commit comments

Comments
 (0)