Skip to content

Commit d5fcd00

Browse files
VGR-GITclaude
andauthored
feat(rewards): add Ondo campaign participant outcome support (#29267)
## **Description** Implement mechanics around Ondo GM participant outcome ## **Changelog** CHANGELOG entry: null ## **Screenshots/Recordings** - On details/stats page, showing that we're still working on it. Only if you opted in and status is pending and no winner code from api response <img width="473" height="406" alt="image" src="https://github.com/user-attachments/assets/11e15e0b-4147-480e-b85a-f5e7bce2423b" /> <img width="500" height="510" alt="image" src="https://github.com/user-attachments/assets/42bdef8a-ebf9-4cc4-a28f-c2f08d5d948a" /> Note that we're no longer showing the banners that were shown in this section when the competition was active. (i.e. qualified, not eligible, ...) - On details/stats page, showing that a user didn't win. Only if you opted in and status is finalized and no winner code from api response <img width="466" height="269" alt="image" src="https://github.com/user-attachments/assets/7d958159-96ea-45c9-ab3a-4f1786df7377" /> <img width="476" height="445" alt="image" src="https://github.com/user-attachments/assets/03b81970-b8f7-4178-8ab4-a83592235fd6" /> - Winning view that we auto show when a user visits the ondo gm details page, the campaign is completed and the outcome status is pending & we have a verification code <img width="484" height="1046" alt="image" src="https://github.com/user-attachments/assets/d249051a-5ff5-406a-b7dc-8457ab42532c" /> The bannner on the details page/stats page for these kind of users: <img width="459" height="256" alt="image" src="https://github.com/user-attachments/assets/ee89f377-55f1-471a-86ad-561f20fcd996" /> <img width="479" height="489" alt="image" src="https://github.com/user-attachments/assets/a21fbb99-98cc-4d0b-94da-f2615df69aa1" /> - On details/stats page, showing that a user won & they'll receive their reward shortly. Only if you opted in and status is finalized and winner code from api response <img width="450" height="240" alt="image" src="https://github.com/user-attachments/assets/e3dcc506-e1b5-41bd-8cf7-6a8c7cbb48e5" /> <img width="481" height="431" alt="image" src="https://github.com/user-attachments/assets/cd47643a-2f10-4833-87de-ad856a951745" /> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces a new authenticated/cached API flow (`getOndoCampaignParticipantOutcome`) and rewires winner navigation/banners across multiple Ondo screens, which could impact post-campaign UX and routing. Data contract changes (new DTO/status) and removed retry/error UI increase the chance of edge-case regressions if the endpoint returns unexpected/null values. > > **Overview** > Adds end-of-campaign *participant outcome* support for Ondo GM, replacing the old “winner code”/leaderboard-based winner detection. > > The UI now fetches `useOndoCampaignParticipantOutcome` when a campaign is complete and the user is opted in, shows new outcome banners (`pending`/`finalized`, winner vs non-winner), and auto-navigates to the winning screen only when the outcome is `pending` and a `winnerVerificationCode` exists. The winning screen now derives the code from the outcome, disables copy when absent, and redirects back to details if the user has no winner code. > > On the backend, replaces `getOndoCampaignWinnerCode` with `getOndoCampaignParticipantOutcome` end-to-end (controller action + data service endpoint `/ondo-gm/:id/outcome/me`), adds a new outcome DTO/status type, and caches non-null outcomes with a TTL. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 81c6f9a. 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: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 34a82a1 commit d5fcd00

27 files changed

Lines changed: 1286 additions & 988 deletions

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

Lines changed: 175 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useGetOndoLeaderboard } from '../hooks/useGetOndoLeaderboard';
1717
import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition';
1818
import { useGetOndoPortfolioPosition } from '../hooks/useGetOndoPortfolioPosition';
1919
import { useGetOndoCampaignDeposits } from '../hooks/useGetOndoCampaignDeposits';
20+
import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome';
2021
import Routes from '../../../../constants/navigation/Routes';
2122

2223
const mockGoBack = jest.fn();
@@ -197,6 +198,21 @@ jest.mock('../components/Campaigns/CampaignOptInSheet', () => {
197198
};
198199
});
199200

201+
jest.mock('../components/RewardsInfoBanner', () => {
202+
const ReactActual = jest.requireActual('react');
203+
const { View, Text } = jest.requireActual('react-native');
204+
return {
205+
__esModule: true,
206+
default: ({ title, description }: { title: string; description: string }) =>
207+
ReactActual.createElement(
208+
View,
209+
{ testID: 'rewards-info-banner' },
210+
ReactActual.createElement(Text, null, title),
211+
ReactActual.createElement(Text, null, description),
212+
),
213+
};
214+
});
215+
200216
jest.mock('../components/RewardsErrorBanner', () => {
201217
const ReactActual = jest.requireActual('react');
202218
const { View, Text, Pressable } = jest.requireActual('react-native');
@@ -260,6 +276,20 @@ const mockUseGetOndoCampaignDeposits =
260276
typeof useGetOndoCampaignDeposits
261277
>;
262278

279+
jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({
280+
__esModule: true,
281+
useOndoCampaignParticipantOutcome: jest.fn(() => ({
282+
outcome: null,
283+
isLoading: false,
284+
hasError: false,
285+
})),
286+
}));
287+
288+
const mockUseOndoCampaignParticipantOutcome =
289+
useOndoCampaignParticipantOutcome as jest.MockedFunction<
290+
typeof useOndoCampaignParticipantOutcome
291+
>;
292+
263293
const mockOndoPrizePool = jest.fn();
264294
jest.mock('../components/Campaigns/OndoPrizePool', () => {
265295
const ReactActual = jest.requireActual('react');
@@ -561,6 +591,11 @@ describe('OndoCampaignDetailsView', () => {
561591
hasError: false,
562592
refetch: jest.fn(),
563593
});
594+
mockUseOndoCampaignParticipantOutcome.mockReturnValue({
595+
outcome: null,
596+
isLoading: false,
597+
hasError: false,
598+
});
564599
mockOndoPrizePool.mockReset();
565600
});
566601

@@ -1233,6 +1268,94 @@ describe('OndoCampaignDetailsView', () => {
12331268
});
12341269
});
12351270

1271+
const completeCampaignStart = new Date();
1272+
completeCampaignStart.setMonth(completeCampaignStart.getMonth() - 1);
1273+
const completeCampaignEnd = new Date();
1274+
completeCampaignEnd.setDate(completeCampaignEnd.getDate() - 1);
1275+
const startDate = completeCampaignStart.toISOString();
1276+
const endDate = completeCampaignEnd.toISOString();
1277+
1278+
const winnerPosition = {
1279+
rank: 1,
1280+
projectedTier: 'TOP',
1281+
qualified: true,
1282+
qualifiedDays: 10,
1283+
totalInTier: 50,
1284+
rateOfReturn: 0.15,
1285+
currentUsdValue: 10000,
1286+
totalUsdDeposited: 10000,
1287+
netDeposit: 10000,
1288+
neighbors: [],
1289+
computedAt: '2024-01-01T00:00:00Z',
1290+
};
1291+
1292+
const setupWinner = () => {
1293+
mockUseRewardCampaigns.mockReturnValue({
1294+
...hookDefaults,
1295+
campaigns: [createTestCampaign({ startDate, endDate })],
1296+
});
1297+
mockUseGetCampaignParticipantStatus.mockReturnValue({
1298+
status: { optedIn: true, participantCount: 1 },
1299+
isLoading: false,
1300+
hasError: false,
1301+
refetch: jest.fn(),
1302+
});
1303+
mockUseOndoCampaignParticipantOutcome.mockReturnValue({
1304+
outcome: {
1305+
subscriptionId: 'sub-1',
1306+
outcomeStatus: 'pending',
1307+
winnerVerificationCode: 'LVL346',
1308+
},
1309+
isLoading: false,
1310+
hasError: false,
1311+
});
1312+
mockUseGetOndoPortfolioPosition.mockReturnValue({
1313+
portfolio: { positions: [{}], summary: {}, computedAt: '' } as never,
1314+
isLoading: false,
1315+
hasError: false,
1316+
hasFetched: true,
1317+
refetch: jest.fn(),
1318+
});
1319+
};
1320+
1321+
const setupWinnerWithPositions = () => {
1322+
mockUseRewardCampaigns.mockReturnValue({
1323+
...hookDefaults,
1324+
campaigns: [
1325+
createTestCampaign({ name: 'Ended Ondo', startDate, endDate }),
1326+
],
1327+
});
1328+
mockUseGetCampaignParticipantStatus.mockReturnValue({
1329+
status: { optedIn: true, participantCount: 1 },
1330+
isLoading: false,
1331+
hasError: false,
1332+
refetch: jest.fn(),
1333+
});
1334+
mockUseOndoCampaignParticipantOutcome.mockReturnValue({
1335+
outcome: {
1336+
subscriptionId: 'sub-1',
1337+
outcomeStatus: 'pending',
1338+
winnerVerificationCode: 'LVL346',
1339+
},
1340+
isLoading: false,
1341+
hasError: false,
1342+
});
1343+
mockUseGetOndoPortfolioPosition.mockReturnValue({
1344+
portfolio: { positions: [{}], summary: {}, computedAt: '' } as never,
1345+
isLoading: false,
1346+
hasError: false,
1347+
hasFetched: true,
1348+
refetch: jest.fn(),
1349+
});
1350+
mockUseGetOndoLeaderboardPosition.mockReturnValue({
1351+
position: winnerPosition,
1352+
isLoading: false,
1353+
hasError: false,
1354+
hasFetched: true,
1355+
refetch: jest.fn(),
1356+
});
1357+
};
1358+
12361359
describe('not-eligible sheet', () => {
12371360
it('shows OndoNotEligibleSheet when portfolio triggers onNotEligible', () => {
12381361
mockUseRewardCampaigns.mockReturnValue({
@@ -1257,6 +1380,15 @@ describe('OndoCampaignDetailsView', () => {
12571380
expect(getByTestId('ondo-not-eligible-sheet')).toBeDefined();
12581381
});
12591382

1383+
it('auto-navigates to winning view on focus when user is a winner and campaign is complete', () => {
1384+
setupWinner();
1385+
render(<OndoCampaignDetailsView />);
1386+
expect(mockNavigate).toHaveBeenCalledWith(
1387+
Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
1388+
{ campaignId: 'campaign-1', campaignName: 'Test Campaign' },
1389+
);
1390+
});
1391+
12601392
it('dismisses OndoNotEligibleSheet when close is pressed', () => {
12611393
mockUseRewardCampaigns.mockReturnValue({
12621394
...hookDefaults,
@@ -1279,33 +1411,47 @@ describe('OndoCampaignDetailsView', () => {
12791411
<OndoCampaignDetailsView />,
12801412
);
12811413
fireEvent.press(getByTestId('ondo-campaign-portfolio-not-eligible'));
1414+
expect(getByTestId('ondo-not-eligible-sheet')).toBeDefined();
12821415
fireEvent.press(getByTestId('ondo-not-eligible-sheet-close'));
12831416
expect(queryByTestId('ondo-not-eligible-sheet')).toBeNull();
12841417
});
1285-
});
12861418

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-
};
1419+
it('does not auto-navigate when campaign is active', () => {
1420+
mockUseRewardCampaigns.mockReturnValue({
1421+
...hookDefaults,
1422+
campaigns: [createTestCampaign()],
1423+
});
1424+
mockUseGetOndoLeaderboardPosition.mockReturnValue({
1425+
position: winnerPosition,
1426+
isLoading: false,
1427+
hasError: false,
1428+
hasFetched: true,
1429+
refetch: jest.fn(),
1430+
});
1431+
render(<OndoCampaignDetailsView />);
1432+
expect(mockNavigate).not.toHaveBeenCalledWith(
1433+
Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
1434+
expect.anything(),
1435+
);
1436+
});
1437+
1438+
it('passes winner outcome props to CampaignStatsSummary when campaign is complete', () => {
1439+
setupWinner();
1440+
render(<OndoCampaignDetailsView />);
1441+
expect(mockCampaignStatsSummary).toHaveBeenCalledWith(
1442+
expect.objectContaining({
1443+
isCampaignComplete: true,
1444+
outcomeStatus: 'pending',
1445+
winnerVerificationCode: 'LVL346',
1446+
}),
1447+
);
1448+
});
12981449

1299-
const setupWinnerWithPositions = () => {
1300-
const { startDate, endDate } = completeCampaignDates();
1450+
it('passes no outcome status to CampaignStatsSummary when user has no outcome', () => {
13011451
mockUseRewardCampaigns.mockReturnValue({
13021452
...hookDefaults,
13031453
campaigns: [
1304-
createTestCampaign({
1305-
name: 'Ended Ondo',
1306-
startDate,
1307-
endDate,
1308-
}),
1454+
createTestCampaign({ name: 'Ended Ondo', startDate, endDate }),
13091455
],
13101456
});
13111457
mockUseGetCampaignParticipantStatus.mockReturnValue({
@@ -1321,58 +1467,23 @@ describe('OndoCampaignDetailsView', () => {
13211467
hasFetched: true,
13221468
refetch: jest.fn(),
13231469
});
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();
13471470
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-
);
1471+
expect(
1472+
mockCampaignStatsSummary.mock.calls.at(-1)?.[0]?.outcomeStatus,
1473+
).toBeUndefined();
13571474
});
13581475

1359-
it('passes onWinnerBannerPress that navigates to the winning view', async () => {
1476+
it('onWinnerPress navigates to the winning view', () => {
13601477
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();
13681478
mockNavigate.mockClear();
1369-
winnerPress?.();
1479+
render(<OndoCampaignDetailsView />);
1480+
const onWinnerPress =
1481+
mockCampaignStatsSummary.mock.calls.at(-1)?.[0]?.onWinnerPress;
1482+
expect(typeof onWinnerPress).toBe('function');
1483+
onWinnerPress();
13701484
expect(mockNavigate).toHaveBeenCalledWith(
13711485
Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
1372-
{
1373-
campaignId: 'campaign-1',
1374-
campaignName: 'Ended Ondo',
1375-
},
1486+
{ campaignId: 'campaign-1', campaignName: 'Ended Ondo' },
13761487
);
13771488
});
13781489
});

0 commit comments

Comments
 (0)