Skip to content

Commit 2ab5074

Browse files
VGR-GITsophieqgu
authored andcommitted
feat(rewards): add Perps Trading campaign participant outcome support
- Add `PERPS_TRADING` to `CampaignType` enum - Add `PerpsTradingCampaignParticipantOutcomeDto` type - Add `getPerpsTradingCampaignParticipantOutcome` to data service, controller (10 min cache), action types, and messenger - Add `usePerpsTradingCampaignParticipantOutcome` hook for fetching outcome - Add `usePerpsTradingCampaignEndedOutcomeToast` hook: shows winner toast (→ winning view) or participant_finalized toast (→ campaigns) - Add `PerpsTradingCampaignWinningView` screen: displays rank and verification code, with copy/mail actions - Add `formatOrdinalRank` utility to formatUtils - Wire navigator with new screen and toast hook - Add en.json strings for toast and winning view Co-authored-by: VGR-GIT <vangulckrik@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> fix(rewards): use COPY_WINNER_VERIFICATION_CODE metric for winning view copy action Replace the incorrect COPY_REFERRAL_CODE button type with a dedicated COPY_WINNER_VERIFICATION_CODE value in the analytics event fired when a user copies their verification code on the Perps Trading winning view. Co-authored-by: VGR-GIT <vangulckrik@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Reduce duplication Add test coverage Fix lint
1 parent dedb5dd commit 2ab5074

61 files changed

Lines changed: 3086 additions & 1830 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,14 @@ jest.mock('./hooks/useReferralDetails', () => ({
255255
}),
256256
}));
257257

258+
jest.mock('./hooks/useOndoOutcomeToast', () => ({
259+
useOndoOutcomeToast: jest.fn(),
260+
}));
261+
262+
jest.mock('./hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({
263+
usePerpsTradingCampaignEndedOutcomeToast: jest.fn(),
264+
}));
265+
258266
// Mock useRewardsNotificationsNudge hook
259267
const mockShowEnableNotificationsNudge = jest.fn(() => false);
260268
const mockCloseEnableNotificationsNudge = jest.fn();
@@ -282,6 +290,8 @@ jest.mock('./hooks/useRewardsToast', () => ({
282290
loading: jest.fn(),
283291
entriesClosed: jest.fn(),
284292
enableNotificationsNudge: jest.fn(),
293+
outcomeWinner: jest.fn(),
294+
outcomeNonWinner: jest.fn(),
285295
},
286296
})),
287297
}));

app/components/UI/Rewards/RewardsNavigator.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata';
3636
import { useReferralDetails } from './hooks/useReferralDetails';
3737
import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge';
3838
import useRewardsToast from './hooks/useRewardsToast';
39+
import { useOndoOutcomeToast } from './hooks/useOndoOutcomeToast';
40+
import { usePerpsTradingCampaignEndedOutcomeToast } from './hooks/usePerpsTradingCampaignEndedOutcomeToast';
3941
import { strings } from '../../../../locales/i18n';
42+
import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView';
4043

4144
let sessionNotificationsNudgeShown = false;
42-
4345
const Stack = createStackNavigator();
4446

4547
const RewardsNavigator: React.FC = () => {
@@ -74,6 +76,10 @@ const RewardsNavigator: React.FC = () => {
7476
// Fetch referral details so referral code is available across all rewards screens
7577
useReferralDetails();
7678

79+
// Outcome toasts for all campaign types — mounted once so they are active regardless of which screen is focused
80+
useOndoOutcomeToast();
81+
usePerpsTradingCampaignEndedOutcomeToast();
82+
7783
const { showToast, RewardsToastOptions } = useRewardsToast();
7884

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

0 commit comments

Comments
 (0)