Skip to content

Commit 711a0e6

Browse files
VGR-GITclaude
andcommitted
feat(rewards): add Perps Trading Campaign feature
Full mobile implementation of the Perps Trading Competition campaign: data layer (new DTOs, RewardsController cached methods, RewardsDataService API calls, messenger actions), Redux state + selectors, three hooks (leaderboard, leaderboard position, prize pool), components (PerpsTradingCampaignLeaderboard, PerpsTradingCampaignPrizePool, PerpsTradingCampaignCTA with perps geo-restriction, PerpsTradingCampaignStatsHeader), three campaign views (details, leaderboard, stats), navigator registration, route constants, CampaignTile routing, tour step view routing, and en.json translations. Tests added for all three hooks and the formatPnl utility. Co-authored-by: VGR-GIT <vangulckrik@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 73a4e87 commit 711a0e6

28 files changed

Lines changed: 3090 additions & 6 deletions

app/components/UI/Rewards/RewardsNavigator.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import OndoCampaignRwaSelectorView from './Views/OndoCampaignRwaSelectorView';
1616
import OndoCampaignPortfolioView from './Views/OndoCampaignPortfolioView';
1717
import OndoCampaignStatsView from './Views/OndoCampaignStatsView';
1818
import CampaignTourStepView from './Views/CampaignTourStepView';
19+
import PerpsTradingCampaignDetailsView from './Views/PerpsTradingCampaignDetailsView';
20+
import PerpsTradingCampaignLeaderboardView from './Views/PerpsTradingCampaignLeaderboardView';
21+
import PerpsTradingCampaignStatsView from './Views/PerpsTradingCampaignStatsView';
1922
import { useDispatch, useSelector } from 'react-redux';
2023
import { selectRewardsSubscriptionId } from '../../../selectors/rewards';
2124
import {
@@ -92,6 +95,8 @@ const RewardsNavigator: React.FC = () => {
9295
navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW);
9396
} else if (pendingDeeplink?.campaign === 'season1') {
9497
navigation.navigate(Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW);
98+
} else if (pendingDeeplink?.campaign === 'perps-comp') {
99+
navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW);
95100
} else if (pendingDeeplink?.page === 'musd') {
96101
navigation.navigate(Routes.REWARDS_MUSD_CALCULATOR_VIEW);
97102
} else if (pendingDeeplink?.page === 'benefits') {
@@ -194,6 +199,21 @@ const RewardsNavigator: React.FC = () => {
194199
component={OndoCampaignStatsView}
195200
options={{ headerShown: false }}
196201
/>
202+
<Stack.Screen
203+
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW}
204+
component={PerpsTradingCampaignDetailsView}
205+
options={{ headerShown: false }}
206+
/>
207+
<Stack.Screen
208+
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD}
209+
component={PerpsTradingCampaignLeaderboardView}
210+
options={{ headerShown: false }}
211+
/>
212+
<Stack.Screen
213+
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS}
214+
component={PerpsTradingCampaignStatsView}
215+
options={{ headerShown: false }}
216+
/>
197217
</>
198218
) : null}
199219
</Stack.Navigator>

app/components/UI/Rewards/Views/CampaignTourStepView.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import ScrollableTabView from '@tommasini/react-native-scrollable-tab-view';
2929
import { selectCampaignById } from '../../../../reducers/rewards/selectors';
3030
import Routes from '../../../../constants/navigation/Routes';
31+
import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types';
3132
import ProgressIndicator from '../components/Onboarding/ProgressIndicator';
3233
import CampaignTourStep, {
3334
CAMPAIGN_TOUR_STEP_TEST_IDS,
@@ -64,12 +65,16 @@ const CampaignTourStepView: React.FC = () => {
6465
>(null);
6566

6667
const navigateToDetails = useCallback(() => {
68+
const detailsRoute =
69+
campaign?.type === CampaignType.PERPS_TRADING
70+
? Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW
71+
: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW;
6772
navigation.dispatch(
68-
StackActions.replace(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, {
73+
StackActions.replace(detailsRoute, {
6974
campaignId,
7075
}),
7176
);
72-
}, [navigation, campaignId]);
77+
}, [navigation, campaignId, campaign]);
7378

7479
const currentStep = tour?.[currentTab];
7580
const isLastStep = tour ? currentTab === tour.length - 1 : false;
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import React, { useCallback, useMemo } from 'react';
2+
import { Pressable, ScrollView } from 'react-native';
3+
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
4+
import { useSelector } from 'react-redux';
5+
import {
6+
Box,
7+
BoxAlignItems,
8+
BoxFlexDirection,
9+
BoxJustifyContent,
10+
FontWeight,
11+
Icon,
12+
IconColor,
13+
IconName,
14+
IconSize,
15+
Text,
16+
TextVariant,
17+
} from '@metamask/design-system-react-native';
18+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
19+
import { SafeAreaView } from 'react-native-safe-area-context';
20+
import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard';
21+
import ErrorBoundary from '../../../Views/ErrorBoundary';
22+
import CampaignStatus from '../components/Campaigns/CampaignStatus';
23+
import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks';
24+
import PerpsTradingCampaignLeaderboard from '../components/Campaigns/PerpsTradingCampaignLeaderboard';
25+
import PerpsTradingCampaignPrizePool from '../components/Campaigns/PerpsTradingCampaignPrizePool';
26+
import PerpsTradingCampaignCTA from '../components/Campaigns/PerpsTradingCampaignCTA';
27+
import PerpsTradingCampaignStatsHeader from '../components/Campaigns/PerpsTradingCampaignStatsHeader';
28+
import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils';
29+
import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus';
30+
import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard';
31+
import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition';
32+
import { useGetPerpsTradingCampaignPrizePool } from '../hooks/useGetPerpsTradingCampaignPrizePool';
33+
import { useRewardCampaigns } from '../hooks/useRewardCampaigns';
34+
import { strings } from '../../../../../locales/i18n';
35+
import Routes from '../../../../constants/navigation/Routes';
36+
import {
37+
CampaignType,
38+
OndoCampaignHowItWorks,
39+
} from '../../../../core/Engine/controllers/rewards-controller/types';
40+
import { selectReferralCode } from '../../../../reducers/rewards/selectors';
41+
import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils';
42+
43+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
44+
type PerpsTradingCampaignDetailsRouteParams = {
45+
RewardsPerpsTradingCampaignDetails: { campaignId?: string };
46+
};
47+
48+
export const PERPS_CAMPAIGN_DETAILS_TEST_IDS = {
49+
CONTAINER: 'perps-campaign-details-container',
50+
} as const;
51+
52+
const PerpsTradingCampaignDetailsView: React.FC = () => {
53+
const tw = useTailwind();
54+
const navigation = useNavigation();
55+
const route =
56+
useRoute<
57+
RouteProp<
58+
PerpsTradingCampaignDetailsRouteParams,
59+
'RewardsPerpsTradingCampaignDetails'
60+
>
61+
>();
62+
const routeCampaignId = route.params?.campaignId;
63+
const referralCode = useSelector(selectReferralCode);
64+
65+
const { campaigns } = useRewardCampaigns();
66+
67+
const campaign = useMemo(
68+
() =>
69+
campaigns.find((c) =>
70+
routeCampaignId
71+
? c.id === routeCampaignId
72+
: c.type === CampaignType.PERPS_TRADING,
73+
) ?? null,
74+
[campaigns, routeCampaignId],
75+
);
76+
77+
const effectiveCampaignId = routeCampaignId ?? campaign?.id ?? '';
78+
79+
const {
80+
status: participantStatusData,
81+
isLoading: isParticipantStatusLoading,
82+
} = useGetCampaignParticipantStatus(effectiveCampaignId || undefined);
83+
84+
const isOptedIn = participantStatusData?.optedIn === true;
85+
const campaignStatus = campaign ? getCampaignStatus(campaign) : null;
86+
const isActive = campaignStatus === 'active';
87+
const isComplete = campaignStatus === 'complete';
88+
89+
const {
90+
leaderboard,
91+
isLoading: isLeaderboardLoading,
92+
hasError: hasLeaderboardError,
93+
isLeaderboardNotYetComputed,
94+
refetch: refetchLeaderboard,
95+
} = useGetPerpsTradingCampaignLeaderboard(effectiveCampaignId || undefined);
96+
97+
const { position, isLoading: isPositionLoading } =
98+
useGetPerpsTradingCampaignLeaderboardPosition(
99+
isOptedIn ? effectiveCampaignId || undefined : undefined,
100+
);
101+
102+
const {
103+
prizePool,
104+
isLoading: isPrizePoolLoading,
105+
hasError: hasPrizePoolError,
106+
refetch: refetchPrizePool,
107+
} = useGetPerpsTradingCampaignPrizePool(effectiveCampaignId || undefined);
108+
109+
const leaderboardUserPosition = useMemo(
110+
() =>
111+
position
112+
? { rank: position.rank, neighbors: position.neighbors ?? [] }
113+
: null,
114+
[position],
115+
);
116+
117+
const navigateToLeaderboard = useCallback(() => {
118+
if (!effectiveCampaignId) return;
119+
navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD, {
120+
campaignId: effectiveCampaignId,
121+
});
122+
}, [navigation, effectiveCampaignId]);
123+
124+
const navigateToStats = useCallback(() => {
125+
if (!effectiveCampaignId) return;
126+
navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS, {
127+
campaignId: effectiveCampaignId,
128+
});
129+
}, [navigation, effectiveCampaignId]);
130+
131+
const navigateToMechanics = useCallback(() => {
132+
if (!effectiveCampaignId) return;
133+
navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, {
134+
campaignId: effectiveCampaignId,
135+
});
136+
}, [navigation, effectiveCampaignId]);
137+
138+
return (
139+
<ErrorBoundary
140+
navigation={navigation}
141+
view="PerpsTradingCampaignDetailsView"
142+
>
143+
<SafeAreaView
144+
edges={{ bottom: 'additive' }}
145+
style={tw.style('flex-1 bg-default')}
146+
testID={PERPS_CAMPAIGN_DETAILS_TEST_IDS.CONTAINER}
147+
>
148+
<HeaderCompactStandard
149+
title={strings('rewards.perps_trading_campaign.title')}
150+
titleProps={{ variant: TextVariant.HeadingSm }}
151+
onBack={() => navigation.goBack()}
152+
backButtonProps={{ testID: 'perps-details-back-button' }}
153+
endButtonIconProps={getCampaignMechanicsButtonProps(
154+
campaign != null,
155+
navigateToMechanics,
156+
'perps-details-mechanics-button',
157+
)}
158+
includesTopInset
159+
/>
160+
161+
<ScrollView
162+
showsVerticalScrollIndicator={false}
163+
contentContainerStyle={tw.style('pb-4')}
164+
>
165+
{/* Campaign status hero */}
166+
{campaign && (
167+
<Box twClassName="p-4">
168+
<CampaignStatus campaign={campaign} />
169+
</Box>
170+
)}
171+
172+
<Box twClassName="border-b border-border-muted" />
173+
174+
{/* Not opted in — show How It Works */}
175+
{!isOptedIn && !isComplete && campaign?.details?.howItWorks && (
176+
<Box twClassName="p-4">
177+
<CampaignHowItWorks
178+
howItWorks={
179+
campaign.details.howItWorks as OndoCampaignHowItWorks
180+
}
181+
/>
182+
</Box>
183+
)}
184+
185+
{/* Opted in + active — Stats summary, prize pool, leaderboard preview */}
186+
{isOptedIn && isActive && (
187+
<>
188+
{/* Stats section (navigates to full stats view) */}
189+
<Pressable onPress={navigateToStats}>
190+
<Box
191+
twClassName="p-4"
192+
flexDirection={BoxFlexDirection.Row}
193+
alignItems={BoxAlignItems.Center}
194+
justifyContent={BoxJustifyContent.Between}
195+
>
196+
<Text
197+
variant={TextVariant.HeadingMd}
198+
fontWeight={FontWeight.Bold}
199+
>
200+
{strings('rewards.perps_trading_campaign.stats_title')}
201+
</Text>
202+
<Icon
203+
name={IconName.ArrowRight}
204+
size={IconSize.Sm}
205+
color={IconColor.IconDefault}
206+
/>
207+
</Box>
208+
</Pressable>
209+
<Box twClassName="px-4 pb-4">
210+
<PerpsTradingCampaignStatsHeader
211+
position={position}
212+
isLoading={isPositionLoading}
213+
/>
214+
</Box>
215+
216+
<Box twClassName="border-b border-border-muted" />
217+
218+
{/* Prize pool section */}
219+
<Box twClassName="p-4">
220+
<Text
221+
variant={TextVariant.HeadingMd}
222+
fontWeight={FontWeight.Bold}
223+
>
224+
{strings('rewards.perps_trading_campaign.prize_pool_title')}
225+
</Text>
226+
<PerpsTradingCampaignPrizePool
227+
totalNotionalVolume={prizePool?.currentNotionalVolume ?? null}
228+
isLoading={isPrizePoolLoading}
229+
hasError={hasPrizePoolError}
230+
refetch={refetchPrizePool}
231+
/>
232+
</Box>
233+
234+
<Box twClassName="border-b border-border-muted" />
235+
236+
{/* Leaderboard preview (navigates to full leaderboard) */}
237+
<Pressable onPress={navigateToLeaderboard}>
238+
<Box
239+
twClassName="p-4"
240+
flexDirection={BoxFlexDirection.Row}
241+
alignItems={BoxAlignItems.Center}
242+
justifyContent={BoxJustifyContent.Between}
243+
>
244+
<Text
245+
variant={TextVariant.HeadingMd}
246+
fontWeight={FontWeight.Bold}
247+
>
248+
{strings(
249+
'rewards.perps_trading_campaign.leaderboard_title',
250+
)}
251+
</Text>
252+
<Icon
253+
name={IconName.ArrowRight}
254+
size={IconSize.Sm}
255+
color={IconColor.IconDefault}
256+
/>
257+
</Box>
258+
</Pressable>
259+
<Box twClassName="pb-4">
260+
<PerpsTradingCampaignLeaderboard
261+
entries={leaderboard?.entries ?? []}
262+
totalParticipants={leaderboard?.totalParticipants ?? 0}
263+
computedAt={leaderboard?.computedAt ?? null}
264+
isLoading={isLeaderboardLoading}
265+
hasError={hasLeaderboardError}
266+
isLeaderboardNotYetComputed={isLeaderboardNotYetComputed}
267+
onRetry={refetchLeaderboard}
268+
currentUserReferralCode={referralCode}
269+
userPosition={leaderboardUserPosition}
270+
maxEntries={5}
271+
campaignId={effectiveCampaignId}
272+
/>
273+
</Box>
274+
</>
275+
)}
276+
277+
{/* Campaign complete — leaderboard only */}
278+
{isComplete && (
279+
<Box twClassName="py-4">
280+
<PerpsTradingCampaignLeaderboard
281+
entries={leaderboard?.entries ?? []}
282+
totalParticipants={leaderboard?.totalParticipants ?? 0}
283+
computedAt={leaderboard?.computedAt ?? null}
284+
isLoading={isLeaderboardLoading}
285+
hasError={hasLeaderboardError}
286+
isLeaderboardNotYetComputed={isLeaderboardNotYetComputed}
287+
onRetry={refetchLeaderboard}
288+
currentUserReferralCode={referralCode}
289+
maxEntries={5}
290+
campaignId={effectiveCampaignId}
291+
/>
292+
</Box>
293+
)}
294+
</ScrollView>
295+
296+
{/* Bottom CTA */}
297+
{campaign && (
298+
<PerpsTradingCampaignCTA
299+
campaign={campaign}
300+
participantStatus={{
301+
status: participantStatusData ?? null,
302+
isLoading: isParticipantStatusLoading,
303+
}}
304+
/>
305+
)}
306+
</SafeAreaView>
307+
</ErrorBoundary>
308+
);
309+
};
310+
311+
export default PerpsTradingCampaignDetailsView;

0 commit comments

Comments
 (0)