Skip to content

Commit b6fcc72

Browse files
VGR-GITclaude
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>
1 parent 6d50069 commit b6fcc72

13 files changed

Lines changed: 1002 additions & 8 deletions

File tree

app/components/UI/Rewards/RewardsNavigator.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ import RewardsUpdateRequired from './components/RewardsUpdateRequired/RewardsUpd
3434
import { useSeasonStatus } from './hooks/useSeasonStatus';
3535
import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata';
3636
import { useReferralDetails } from './hooks/useReferralDetails';
37-
37+
import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView';
38+
import { usePerpsTradingCampaignEndedOutcomeToast } from './hooks/usePerpsTradingCampaignEndedOutcomeToast';
3839
const Stack = createStackNavigator();
3940

4041
const RewardsNavigator: React.FC = () => {
@@ -69,6 +70,8 @@ const RewardsNavigator: React.FC = () => {
6970
// Fetch referral details so referral code is available across all rewards screens
7071
useReferralDetails();
7172

73+
usePerpsTradingCampaignEndedOutcomeToast();
74+
7275
// Determine initial route - always start with onboarding intro step initially
7376
const getInitialRoute = () => {
7477
// If user has already opted in and has a valid subscription candidate ID, go to dashboard
@@ -212,6 +215,10 @@ const RewardsNavigator: React.FC = () => {
212215
<Stack.Screen
213216
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS}
214217
component={PerpsTradingCampaignStatsView}
218+
/>
219+
<Stack.Screen
220+
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW}
221+
component={PerpsTradingCampaignWinningView}
215222
options={{ headerShown: false }}
216223
/>
217224
</>
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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 PerpsTradingCampaignWinningView, {
6+
PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS,
7+
} from './PerpsTradingCampaignWinningView';
8+
import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome';
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+
useRoute: () => ({
21+
params: { campaignId: 'campaign-perps-1', campaignName: 'Perps Campaign' },
22+
}),
23+
}));
24+
25+
jest.mock('@metamask/design-system-twrnc-preset', () => {
26+
const tw = (...args: unknown[]) => args;
27+
tw.style = (...args: unknown[]) => args;
28+
return { useTailwind: () => tw };
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('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({
40+
usePerpsTradingCampaignParticipantOutcome: jest.fn(),
41+
}));
42+
43+
const mockUseOutcome =
44+
usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction<
45+
typeof usePerpsTradingCampaignParticipantOutcome
46+
>;
47+
48+
jest.mock('../../../Views/ErrorBoundary', () => {
49+
const ReactActual = jest.requireActual('react');
50+
const { View } = jest.requireActual('react-native');
51+
return {
52+
__esModule: true,
53+
default: ({ children }: { children: React.ReactNode }) =>
54+
ReactActual.createElement(View, null, children),
55+
};
56+
});
57+
58+
jest.mock('../hooks/useTrackRewardsPageView', () => ({
59+
__esModule: true,
60+
default: jest.fn(),
61+
}));
62+
63+
jest.mock('../../../../core/Analytics', () => ({
64+
MetaMetricsEvents: {
65+
REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED',
66+
},
67+
}));
68+
69+
jest.mock('../utils', () => ({
70+
RewardsMetricsButtons: {
71+
COPY_REFERRAL_CODE: 'copy_referral_code',
72+
},
73+
}));
74+
75+
const mockTrackEvent = jest.fn();
76+
const mockBuild = jest.fn(() => ({}));
77+
jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
78+
useAnalytics: () => ({
79+
trackEvent: mockTrackEvent,
80+
createEventBuilder: () => ({
81+
addProperties: () => ({ build: mockBuild }),
82+
}),
83+
}),
84+
}));
85+
86+
jest.mock('../components/ReferralDetails/CopyableField', () => {
87+
const ReactActual = jest.requireActual('react');
88+
const { View, Text, Pressable } = jest.requireActual('react-native');
89+
return {
90+
__esModule: true,
91+
default: ({
92+
label,
93+
value,
94+
onCopy,
95+
}: {
96+
label: string;
97+
value?: string | null;
98+
onCopy?: () => void;
99+
}) =>
100+
ReactActual.createElement(
101+
View,
102+
{ testID: 'copyable-field' },
103+
ReactActual.createElement(Text, null, label),
104+
ReactActual.createElement(
105+
Text,
106+
{ testID: 'copyable-value' },
107+
value ?? '',
108+
),
109+
ReactActual.createElement(Pressable, {
110+
testID: 'copyable-trigger',
111+
onPress: onCopy,
112+
}),
113+
),
114+
};
115+
});
116+
117+
jest.mock('../../../../../locales/i18n', () => ({
118+
strings: jest.fn(
119+
(key: string, params?: { place?: string; code?: string }) => {
120+
const map: Record<string, string> = {
121+
'rewards.perps_trading_campaign_winning.you_won': 'You won',
122+
'rewards.perps_trading_campaign_winning.email_instructions':
123+
'Email perpscampaign@consensys.net with your code to claim your prize.',
124+
'rewards.perps_trading_campaign_winning.open_mail': 'Open mail',
125+
'rewards.perps_trading_campaign_winning.skip_for_now': 'Skip for now',
126+
'rewards.perps_trading_campaign_winning.mail_subject':
127+
'Perps Trading campaign prize claim',
128+
'rewards.perps_trading_campaign_winning.mail_body': `My winning code: ${params?.code ?? ''}`,
129+
'rewards.perps_trading_campaign_winning.winning_code': 'Winning code',
130+
'rewards.perps_trading_campaign_winning.close_a11y': 'Close',
131+
};
132+
if (
133+
key === 'rewards.perps_trading_campaign_winning.rank_label' &&
134+
params?.place
135+
) {
136+
return `${params.place} place`;
137+
}
138+
return map[key] ?? key;
139+
},
140+
),
141+
}));
142+
143+
describe('PerpsTradingCampaignWinningView', () => {
144+
beforeEach(() => {
145+
jest.clearAllMocks();
146+
mockUseOutcome.mockReturnValue({
147+
outcome: {
148+
subscriptionId: 'sub-1',
149+
outcomeStatus: 'pending',
150+
winnerVerificationCode: 'PERPS-WIN-99',
151+
rank: 3,
152+
},
153+
isLoading: false,
154+
hasError: false,
155+
});
156+
});
157+
158+
it('renders the main container', () => {
159+
const { getByTestId } = render(<PerpsTradingCampaignWinningView />);
160+
expect(
161+
getByTestId(PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER),
162+
).toBeTruthy();
163+
});
164+
165+
it('shows "You won" and the rank from outcome', () => {
166+
const { getByText } = render(<PerpsTradingCampaignWinningView />);
167+
expect(getByText('You won')).toBeTruthy();
168+
expect(getByText('3rd place')).toBeTruthy();
169+
});
170+
171+
it('shows dash rank when outcome has no rank', () => {
172+
mockUseOutcome.mockReturnValue({
173+
outcome: {
174+
subscriptionId: 'sub-1',
175+
outcomeStatus: 'pending',
176+
winnerVerificationCode: 'PERPS-WIN-99',
177+
rank: null,
178+
},
179+
isLoading: false,
180+
hasError: false,
181+
});
182+
const { getByText } = render(<PerpsTradingCampaignWinningView />);
183+
expect(getByText('—')).toBeTruthy();
184+
});
185+
186+
it('calls goBack when Skip for now is pressed', () => {
187+
const { getByText } = render(<PerpsTradingCampaignWinningView />);
188+
fireEvent.press(getByText('Skip for now'));
189+
expect(mockGoBack).toHaveBeenCalledTimes(1);
190+
});
191+
192+
it('calls goBack when close button is pressed', () => {
193+
const { getByLabelText } = render(<PerpsTradingCampaignWinningView />);
194+
fireEvent.press(getByLabelText('Close'));
195+
expect(mockGoBack).toHaveBeenCalledTimes(1);
196+
});
197+
198+
it('copies winning code and tracks analytics when copy is triggered', () => {
199+
const setStringSpy = jest.spyOn(Clipboard, 'setString');
200+
const { getByTestId } = render(<PerpsTradingCampaignWinningView />);
201+
fireEvent.press(getByTestId('copyable-trigger'));
202+
expect(setStringSpy).toHaveBeenCalledWith('PERPS-WIN-99');
203+
expect(mockTrackEvent).toHaveBeenCalled();
204+
});
205+
206+
it('opens mailto with the correct email and code when Open mail is pressed', async () => {
207+
const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
208+
const { getByText } = render(<PerpsTradingCampaignWinningView />);
209+
fireEvent.press(getByText('Open mail'));
210+
expect(openSpy).toHaveBeenCalled();
211+
const url = openSpy.mock.calls[0][0] as string;
212+
expect(url).toContain('mailto:perpscampaign@consensys.net');
213+
expect(url).toContain(encodeURIComponent('PERPS-WIN-99'));
214+
openSpy.mockRestore();
215+
});
216+
217+
it('navigates to campaigns view when outcome has no verification code after loading', () => {
218+
mockUseOutcome.mockReturnValue({
219+
outcome: {
220+
subscriptionId: 'sub-1',
221+
outcomeStatus: 'finalized',
222+
winnerVerificationCode: null,
223+
rank: 21,
224+
},
225+
isLoading: false,
226+
hasError: false,
227+
});
228+
render(<PerpsTradingCampaignWinningView />);
229+
expect(mockNavigate).toHaveBeenCalledWith(
230+
expect.stringContaining('Campaigns'),
231+
);
232+
});
233+
});

0 commit comments

Comments
 (0)