Skip to content

Commit edf69ec

Browse files
VGR-GITsophieqguclaude
authored
feat(rewards): ondo campaign winner page (#29158)
## Context Builds on top of #29017 (`feat(rewards): Add ondo campaign winning toast`). Makes it so the scope of the piece of work for this & the targeting pr branch is only about the winning view that can be navigated to from a card/banner shown in stats section on details page OR on dedicated stats page. ## Changelog CHANGELOG entry: null ## Screenshots Winning page, auto opened when qualified and user visits detail page. Skip for now or X at top right closes/navigates back to detail page. <img width="492" height="1010" alt="image" src="https://github.com/user-attachments/assets/2efc875f-c654-4deb-96e0-7afd501a9913" /> Primary CTA opens mail and has the winning code in subject <img width="492" height="1010" alt="image" src="https://github.com/user-attachments/assets/60cc2033-b90c-427e-9e00-b2225d129f34" /> Details page showing banner that you won; tapping it navigates back to winning page <img width="492" height="1010" alt="image" src="https://github.com/user-attachments/assets/bcbaae2e-0df2-45a4-87d0-c23658b93d9f" /> Stats page also showing the winning banner. If user skips or taps the x at top right it closes/navigates back to stats page <img width="492" height="1010" alt="image" src="https://github.com/user-attachments/assets/872c31d1-7e3a-4ba8-a947-6f13a066f6aa" /> If for some reason the position isn't available and not loading <img width="901" height="1510" alt="image" src="https://github.com/user-attachments/assets/db349479-c78f-4a98-abbc-f695ad675efa" /> If for some reason the position isn't available and we are loading it <img width="901" height="1510" alt="image" src="https://github.com/user-attachments/assets/86d3b7e6-c719-445a-a48b-12ff8a985ba1" /> If for some reason the winning code isn't available and we had an error fetching it <img width="1004" height="1688" alt="image" src="https://github.com/user-attachments/assets/48178a90-9957-4876-b26e-174e1107116a" /> If for some reason the winning code isn't available and we're loading it <img width="1004" height="1688" alt="image" src="https://github.com/user-attachments/assets/cffd4651-86e5-4d51-90fa-cf643241b8d0" /> Note there's a animated border effect on the winning banner just like in the figma design. It loops in an interval. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new authenticated rewards endpoint and client auto-navigation to a new winner flow; issues could mis-route users or surface incorrect winner/claim state if winner detection or API handling is wrong. > > **Overview** > Adds a new `RewardsOndoCampaignWinning` flow for completed Ondo campaigns, including a dedicated winning screen that displays rank/return, fetches a per-user winner code, supports copy-to-clipboard, and launches a prefilled `mailto:` claim email (with retry/error/loading states). > > Updates Ondo campaign details and stats screens to detect *top-5 qualified winners* (`isOndoCampaignWinner`), show a tappable animated winner banner, and auto-navigate to the winning screen once per session on screen focus; also threads `campaignName` through stats navigation and adds route fallback behavior when `campaignId` is missing. Backend wiring includes new `RewardsController:getOndoCampaignWinnerCode` + data-service endpoint (`/ondo-gm/:campaignId/winner-code/me`) and supporting tests/i18n/assets. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 470485c. 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> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d0d7530 commit edf69ec

29 files changed

Lines changed: 2254 additions & 57 deletions

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ jest.mock('../../../core/Engine', () => ({
1717

1818
// Mock dependencies
1919
jest.mock('./hooks/useOptIn');
20-
jest.mock('./hooks/useSeasonStatus', () => ({
21-
useSeasonStatus: jest.fn(),
22-
}));
2320

2421
jest.mock('./OnboardingNavigator', () => {
2522
const ReactActual = jest.requireActual('react');

app/components/UI/Rewards/RewardsNavigator.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ReferralRewardsView from './Views/RewardsReferralView';
77
import RewardsSettingsView from './Views/RewardsSettingsView';
88
import CampaignsView from './Views/CampaignsView';
99
import OndoCampaignDetailsView from './Views/OndoCampaignDetailsView';
10+
import OndoCampaignWinningView from './Views/OndoCampaignWinningView';
1011
import SeasonOneCampaignDetailsView from './Views/SeasonOneCampaignDetailsView';
1112
import CampaignMechanicsView from './Views/CampaignMechanicsView';
1213
import MusdCalculatorView from './Views/MusdCalculatorView';
@@ -24,12 +25,13 @@ import {
2425
import { setPendingDeeplink } from '../../../reducers/rewards';
2526
import { useCandidateSubscriptionId } from './hooks/useCandidateSubscriptionId';
2627
import { useNavigation } from '@react-navigation/native';
27-
import { useSeasonStatus } from './hooks/useSeasonStatus';
2828
import { useTheme } from '../../../util/theme';
29-
import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata';
3029
import useRewardsVersionGuard from './hooks/useRewardsVersionGuard';
31-
import { useReferralDetails } from './hooks/useReferralDetails';
3230
import RewardsUpdateRequired from './components/RewardsUpdateRequired/RewardsUpdateRequired';
31+
import { useSeasonStatus } from './hooks/useSeasonStatus';
32+
import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata';
33+
import { useReferralDetails } from './hooks/useReferralDetails';
34+
3335
const Stack = createStackNavigator();
3436

3537
const RewardsNavigator: React.FC = () => {
@@ -152,6 +154,11 @@ const RewardsNavigator: React.FC = () => {
152154
component={OndoCampaignDetailsView}
153155
options={{ headerShown: false }}
154156
/>
157+
<Stack.Screen
158+
name={Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW}
159+
component={OndoCampaignWinningView}
160+
options={{ headerShown: false }}
161+
/>
155162
<Stack.Screen
156163
name={Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW}
157164
component={SeasonOneCampaignDetailsView}

app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx

Lines changed: 182 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React from 'react';
2-
import { render, fireEvent } from '@testing-library/react-native';
2+
import { render, fireEvent, waitFor } from '@testing-library/react-native';
33
import OndoCampaignDetailsView, {
44
CAMPAIGN_DETAILS_TEST_IDS,
5+
resetOndoCampaignDetailsSessionAutoNavigationForTests,
56
} from './OndoCampaignDetailsView';
67
import { CAMPAIGN_STATS_SUMMARY_TEST_IDS } from '../components/Campaigns/CampaignStatsSummary';
78
import { ONDO_PRIZE_POOL_TEST_IDS } from '../components/Campaigns/OndoPrizePool';
@@ -21,15 +22,29 @@ import Routes from '../../../../constants/navigation/Routes';
2122
const mockGoBack = jest.fn();
2223
const mockNavigate = jest.fn();
2324

24-
jest.mock('@react-navigation/native', () => ({
25-
useNavigation: () => ({
26-
goBack: mockGoBack,
27-
navigate: mockNavigate,
28-
addListener: jest.fn(() => jest.fn()),
29-
isFocused: () => true,
30-
}),
31-
useRoute: () => ({ params: { campaignId: 'campaign-1' } }),
32-
}));
25+
/** Mutable route params so tests can cover deeplink-style navigation without `campaignId`. */
26+
const mockRouteState: { params: { campaignId?: string } } = {
27+
params: { campaignId: 'campaign-1' },
28+
};
29+
30+
jest.mock('@react-navigation/native', () => {
31+
const ReactActual = jest.requireActual('react');
32+
return {
33+
useNavigation: () => ({
34+
goBack: mockGoBack,
35+
navigate: mockNavigate,
36+
addListener: jest.fn(() => jest.fn()),
37+
isFocused: () => true,
38+
}),
39+
useRoute: () => mockRouteState,
40+
useFocusEffect: (effect: () => void | (() => void)) => {
41+
ReactActual.useEffect(() => {
42+
const cleanup = effect();
43+
return typeof cleanup === 'function' ? cleanup : undefined;
44+
}, [effect]);
45+
},
46+
};
47+
});
3348

3449
jest.mock('@metamask/design-system-react-native', () => {
3550
const actual = jest.requireActual('@metamask/design-system-react-native');
@@ -460,6 +475,8 @@ jest.mock('../../../../../locales/i18n', () => ({
460475
'Competition no longer open',
461476
'rewards.campaign_details.competition_closed_description':
462477
'Entries are now closed',
478+
'rewards.ondo_campaign_portfolio.view_activity': 'View activity',
479+
'rewards.ondo_campaign_stats.title': 'Your stats',
463480
};
464481
return translations[key] || key;
465482
},
@@ -501,6 +518,8 @@ const hookDefaults = {
501518

502519
describe('OndoCampaignDetailsView', () => {
503520
beforeEach(() => {
521+
resetOndoCampaignDetailsSessionAutoNavigationForTests();
522+
mockRouteState.params = { campaignId: 'campaign-1' };
504523
jest.clearAllMocks();
505524
mockIsTokenTradingOpen.mockReturnValue(true);
506525
mockCampaignStatsSummary.mockReset();
@@ -1103,7 +1122,7 @@ describe('OndoCampaignDetailsView', () => {
11031122
fireEvent.press(getByTestId('ondo-campaign-details-stats-pressable'));
11041123
expect(mockNavigate).toHaveBeenCalledWith(
11051124
Routes.REWARDS_ONDO_CAMPAIGN_STATS,
1106-
{ campaignId: 'campaign-1' },
1125+
{ campaignId: 'campaign-1', campaignName: 'Test Campaign' },
11071126
);
11081127
});
11091128
});
@@ -1264,4 +1283,156 @@ describe('OndoCampaignDetailsView', () => {
12641283
expect(queryByTestId('ondo-not-eligible-sheet')).toBeNull();
12651284
});
12661285
});
1286+
1287+
describe('winner auto-navigation on focus', () => {
1288+
const completeCampaignDates = () => {
1289+
const lastMonth = new Date();
1290+
lastMonth.setMonth(lastMonth.getMonth() - 1);
1291+
const yesterday = new Date();
1292+
yesterday.setDate(yesterday.getDate() - 1);
1293+
return {
1294+
startDate: lastMonth.toISOString(),
1295+
endDate: yesterday.toISOString(),
1296+
};
1297+
};
1298+
1299+
const setupWinnerWithPositions = () => {
1300+
const { startDate, endDate } = completeCampaignDates();
1301+
mockUseRewardCampaigns.mockReturnValue({
1302+
...hookDefaults,
1303+
campaigns: [
1304+
createTestCampaign({
1305+
name: 'Ended Ondo',
1306+
startDate,
1307+
endDate,
1308+
}),
1309+
],
1310+
});
1311+
mockUseGetCampaignParticipantStatus.mockReturnValue({
1312+
status: { optedIn: true, participantCount: 1 },
1313+
isLoading: false,
1314+
hasError: false,
1315+
refetch: jest.fn(),
1316+
});
1317+
mockUseGetOndoPortfolioPosition.mockReturnValue({
1318+
portfolio: { positions: [{}], summary: {}, computedAt: '' } as never,
1319+
isLoading: false,
1320+
hasError: false,
1321+
hasFetched: true,
1322+
refetch: jest.fn(),
1323+
});
1324+
mockUseGetOndoLeaderboardPosition.mockReturnValue({
1325+
position: {
1326+
rank: 2,
1327+
projectedTier: 'MID',
1328+
qualified: true,
1329+
qualifiedDays: 10,
1330+
totalInTier: 50,
1331+
rateOfReturn: 0.1,
1332+
currentUsdValue: 5000,
1333+
totalUsdDeposited: 5000,
1334+
netDeposit: 5000,
1335+
neighbors: [],
1336+
computedAt: '2024-01-01T00:00:00Z',
1337+
},
1338+
isLoading: false,
1339+
hasError: false,
1340+
hasFetched: true,
1341+
refetch: jest.fn(),
1342+
});
1343+
};
1344+
1345+
it('navigates to the winning view once when campaign is complete and user is a qualified top-5 winner', async () => {
1346+
setupWinnerWithPositions();
1347+
render(<OndoCampaignDetailsView />);
1348+
await waitFor(() =>
1349+
expect(mockNavigate).toHaveBeenCalledWith(
1350+
Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
1351+
{
1352+
campaignId: 'campaign-1',
1353+
campaignName: 'Ended Ondo',
1354+
},
1355+
),
1356+
);
1357+
});
1358+
1359+
it('passes onWinnerBannerPress that navigates to the winning view', async () => {
1360+
setupWinnerWithPositions();
1361+
render(<OndoCampaignDetailsView />);
1362+
await waitFor(() => expect(mockCampaignStatsSummary).toHaveBeenCalled());
1363+
const winnerPress = mockCampaignStatsSummary.mock.calls
1364+
.map((c) => c[0] as { onWinnerBannerPress?: () => void })
1365+
.map((p) => p.onWinnerBannerPress)
1366+
.find(Boolean);
1367+
expect(winnerPress).toBeDefined();
1368+
mockNavigate.mockClear();
1369+
winnerPress?.();
1370+
expect(mockNavigate).toHaveBeenCalledWith(
1371+
Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
1372+
{
1373+
campaignId: 'campaign-1',
1374+
campaignName: 'Ended Ondo',
1375+
},
1376+
);
1377+
});
1378+
});
1379+
1380+
describe('route params without campaignId', () => {
1381+
it('resolves the ONDO_HOLDING campaign by type when campaignId is missing from the route', () => {
1382+
mockRouteState.params = {};
1383+
mockUseRewardCampaigns.mockReturnValue({
1384+
...hookDefaults,
1385+
campaigns: [
1386+
createTestCampaign({ id: 'resolved-from-type', name: 'Type Match' }),
1387+
],
1388+
});
1389+
const { getAllByText } = render(<OndoCampaignDetailsView />);
1390+
expect(getAllByText('Type Match').length).toBeGreaterThan(0);
1391+
expect(mockUseGetOndoCampaignDeposits).toHaveBeenCalledWith(
1392+
'resolved-from-type',
1393+
);
1394+
});
1395+
});
1396+
1397+
describe('portfolio and leaderboard navigation', () => {
1398+
const setupOptedInWithPositions = () => {
1399+
mockUseRewardCampaigns.mockReturnValue({
1400+
...hookDefaults,
1401+
campaigns: [createTestCampaign()],
1402+
});
1403+
mockUseGetCampaignParticipantStatus.mockReturnValue({
1404+
status: { optedIn: true, participantCount: 1 },
1405+
isLoading: false,
1406+
hasError: false,
1407+
refetch: jest.fn(),
1408+
});
1409+
mockUseGetOndoPortfolioPosition.mockReturnValue({
1410+
portfolio: { positions: [{}], summary: {}, computedAt: '' } as never,
1411+
isLoading: false,
1412+
hasError: false,
1413+
hasFetched: true,
1414+
refetch: jest.fn(),
1415+
});
1416+
};
1417+
1418+
it('navigates to portfolio activity when View activity is pressed', () => {
1419+
setupOptedInWithPositions();
1420+
const { getByText } = render(<OndoCampaignDetailsView />);
1421+
fireEvent.press(getByText('View activity'));
1422+
expect(mockNavigate).toHaveBeenCalledWith(
1423+
Routes.REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW,
1424+
{ campaignId: 'campaign-1' },
1425+
);
1426+
});
1427+
1428+
it('navigates to full leaderboard when the leaderboard header is pressed', () => {
1429+
setupOptedInWithPositions();
1430+
const { getByText } = render(<OndoCampaignDetailsView />);
1431+
fireEvent.press(getByText('rewards.ondo_campaign_leaderboard.title'));
1432+
expect(mockNavigate).toHaveBeenCalledWith(
1433+
Routes.REWARDS_ONDO_CAMPAIGN_LEADERBOARD,
1434+
{ campaignId: 'campaign-1' },
1435+
);
1436+
});
1437+
});
12671438
});

0 commit comments

Comments
 (0)