Skip to content

Commit a8d18ab

Browse files
sophieqguVGR-GIT
andauthored
feat: Ondo campaign activity view (#28360)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Add Ondo campaign activity view, align existing components more closely with design <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-04-06 at 15 08 55" src="https://github.com/user-attachments/assets/6476ce48-2b7b-471e-adb0-7f3aee68aa39" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-04-06 at 15 09 00" src="https://github.com/user-attachments/assets/87d6f68b-0e6b-4a99-b6d8-6b12558f31ea" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-04-06 at 15 19 46" src="https://github.com/user-attachments/assets/36c12855-e879-4493-aebf-c5e37b68e630" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-04-06 at 15 19 51" src="https://github.com/user-attachments/assets/ff0026cf-115f-4690-be3e-6402fa368e9d" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-04-06 at 15 09 07" src="https://github.com/user-attachments/assets/ddd830b8-e045-4ba2-b9be-940e8bff7954" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-04-06 at 15 09 12" src="https://github.com/user-attachments/assets/27f7e195-e693-475d-b5b9-f4a634defd94" /> <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new rewards activity fetching/caching path (controller + data service + Redux) and a new navigation destination, which can impact data freshness and UI state if pagination/caching edge cases are missed. > > **Overview** > Adds a new `OndoCampaignPortfolioView` (reachable from campaign details) that shows the user’s Ondo GM portfolio plus a paginated activity feed with pull-to-refresh, loading/empty/error states. > > Updates the Ondo campaign details bottom action to a unified `CampaignCTA` with multiple states (join, entries-closed disabled w/ toast, open position, swap assets) and adjusts section gating/UX to align with the new portfolio navigation. > > Introduces a new rewards controller/data-service API for `getOndoCampaignActivity` (including 1-minute first-page caching + last-updated change detection), wires it through a new `useGetOndoCampaignActivity` hook and `ondoCampaignActivity` Redux storage, and refactors shared formatting helpers into `utils/formatUtils` (percent/timestamp/CAIP parsing) used by leaderboard/portfolio/activity components. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9bec37b. 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: VGR <VanGulckRik@gmail.com>
1 parent e259aee commit a8d18ab

44 files changed

Lines changed: 3370 additions & 670 deletions

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.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import SeasonOneCampaignDetailsView from './Views/SeasonOneCampaignDetailsView';
1111
import CampaignMechanicsView from './Views/CampaignMechanicsView';
1212
import MusdCalculatorView from './Views/MusdCalculatorView';
1313
import OndoLeaderboardView from './Views/OndoLeaderboardView';
14+
import OndoCampaignPortfolioView from './Views/OndoCampaignPortfolioView';
1415
import { useSelector } from 'react-redux';
1516
import { selectRewardsSubscriptionId } from '../../../selectors/rewards';
1617
import { selectIsRewardsVersionBlocked } from '../../../reducers/rewards/selectors';
@@ -123,6 +124,11 @@ const RewardsNavigator: React.FC = () => {
123124
component={OndoLeaderboardView}
124125
options={{ headerShown: false }}
125126
/>
127+
<Stack.Screen
128+
name={Routes.REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW}
129+
component={OndoCampaignPortfolioView}
130+
options={{ headerShown: false }}
131+
/>
126132
</>
127133
) : null}
128134
</Stack.Navigator>

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

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { render, fireEvent } from '@testing-library/react-native';
33
import OndoCampaignDetailsView, {
44
CAMPAIGN_DETAILS_TEST_IDS,
55
} from './OndoCampaignDetailsView';
6-
import { CAMPAIGN_JOIN_CTA_TEST_IDS } from '../components/Campaigns/CampaignJoinCTA';
6+
import { CAMPAIGN_CTA_TEST_IDS } from '../components/Campaigns/CampaignCTA';
77
import {
88
type CampaignDto,
99
CampaignType,
@@ -178,6 +178,18 @@ jest.mock('../components/RewardsInfoBanner', () => {
178178
};
179179
});
180180

181+
jest.mock('../hooks/useRewardsToast', () => ({
182+
__esModule: true,
183+
default: () => ({
184+
showToast: jest.fn(),
185+
RewardsToastOptions: {
186+
success: jest.fn(),
187+
error: jest.fn(),
188+
entriesClosed: jest.fn(() => ({ variant: 'icon' })),
189+
},
190+
}),
191+
}));
192+
181193
jest.mock('../components/Campaigns/CampaignOptInSheet', () => {
182194
const ReactActual = jest.requireActual('react');
183195
const { View } = jest.requireActual('react-native');
@@ -257,6 +269,11 @@ jest.mock('../../../../../locales/i18n', () => ({
257269
'rewards.campaigns_view.error_description': 'Please try again.',
258270
'rewards.campaigns_view.retry_button': 'Retry',
259271
'rewards.campaign_details.join_campaign': 'Join Campaign',
272+
'rewards.campaign_details.open_position': 'Open Position',
273+
'rewards.campaign_details.swap_ondo_assets': 'Swap Ondo Assets',
274+
'rewards.campaign_details.entries_closed_title': 'Entries closed',
275+
'rewards.campaign_details.entries_closed_description':
276+
'You missed the opt-in window',
260277
'rewards.campaign_details.competition_closed_title':
261278
'Competition no longer open',
262279
'rewards.campaign_details.competition_closed_description':
@@ -539,7 +556,7 @@ describe('OndoCampaignDetailsView', () => {
539556
});
540557
// status null → participantStatus?.optedIn !== true
541558
const { getByTestId } = render(<OndoCampaignDetailsView />);
542-
expect(getByTestId(CAMPAIGN_JOIN_CTA_TEST_IDS.CTA_BUTTON)).toBeDefined();
559+
expect(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeDefined();
543560
});
544561

545562
it('renders the join CTA when participant is not opted in', () => {
@@ -554,10 +571,10 @@ describe('OndoCampaignDetailsView', () => {
554571
refetch: jest.fn(),
555572
});
556573
const { getByTestId } = render(<OndoCampaignDetailsView />);
557-
expect(getByTestId(CAMPAIGN_JOIN_CTA_TEST_IDS.CTA_BUTTON)).toBeDefined();
574+
expect(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeDefined();
558575
});
559576

560-
it('does not render the CTA when participant is already opted in', () => {
577+
it('renders "Open Position" CTA when participant is opted in with no positions', () => {
561578
mockUseRewardCampaigns.mockReturnValue({
562579
...hookDefaults,
563580
campaigns: [createTestCampaign()],
@@ -568,8 +585,32 @@ describe('OndoCampaignDetailsView', () => {
568585
hasError: false,
569586
refetch: jest.fn(),
570587
});
571-
const { queryByTestId } = render(<OndoCampaignDetailsView />);
572-
expect(queryByTestId(CAMPAIGN_JOIN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull();
588+
const { getByTestId, getByText } = render(<OndoCampaignDetailsView />);
589+
expect(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeDefined();
590+
expect(getByText('Open Position')).toBeDefined();
591+
});
592+
593+
it('renders "Swap Ondo Assets" CTA when participant is opted in with positions', () => {
594+
mockUseRewardCampaigns.mockReturnValue({
595+
...hookDefaults,
596+
campaigns: [createTestCampaign()],
597+
});
598+
mockUseGetCampaignParticipantStatus.mockReturnValue({
599+
status: { optedIn: true, participantCount: 1 },
600+
isLoading: false,
601+
hasError: false,
602+
refetch: jest.fn(),
603+
});
604+
mockUseGetOndoPortfolioPosition.mockReturnValue({
605+
portfolio: { positions: [{}], summary: {}, computedAt: '' } as never,
606+
isLoading: false,
607+
hasError: false,
608+
hasFetched: true,
609+
refetch: jest.fn(),
610+
});
611+
const { getByTestId, getByText } = render(<OndoCampaignDetailsView />);
612+
expect(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeDefined();
613+
expect(getByText('Swap Ondo Assets')).toBeDefined();
573614
});
574615

575616
it('hides the CTA while participant status is loading', () => {
@@ -584,12 +625,12 @@ describe('OndoCampaignDetailsView', () => {
584625
refetch: jest.fn(),
585626
});
586627
const { queryByTestId } = render(<OndoCampaignDetailsView />);
587-
expect(queryByTestId(CAMPAIGN_JOIN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull();
628+
expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull();
588629
});
589630

590631
it('does not render CTA when no campaign is loaded', () => {
591632
const { queryByTestId } = render(<OndoCampaignDetailsView />);
592-
expect(queryByTestId(CAMPAIGN_JOIN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull();
633+
expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull();
593634
});
594635

595636
it('opens the opt-in sheet when CTA is pressed', () => {
@@ -598,7 +639,7 @@ describe('OndoCampaignDetailsView', () => {
598639
campaigns: [createTestCampaign()],
599640
});
600641
const { getByTestId } = render(<OndoCampaignDetailsView />);
601-
fireEvent.press(getByTestId(CAMPAIGN_JOIN_CTA_TEST_IDS.CTA_BUTTON));
642+
fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON));
602643
expect(getByTestId('campaign-opt-in-sheet')).toBeDefined();
603644
});
604645

@@ -621,7 +662,7 @@ describe('OndoCampaignDetailsView', () => {
621662
expect(mockNavigate).toHaveBeenCalledWith(Routes.REWARDS_CAMPAIGNS_VIEW);
622663
});
623664

624-
it('does not render the CTA when entries are closed (past deposit cutoff)', () => {
665+
it('renders disabled CTA button when entries are closed (past deposit cutoff)', () => {
625666
mockUseRewardCampaigns.mockReturnValue({
626667
...hookDefaults,
627668
campaigns: [
@@ -633,8 +674,9 @@ describe('OndoCampaignDetailsView', () => {
633674
}),
634675
],
635676
});
636-
const { queryByTestId } = render(<OndoCampaignDetailsView />);
637-
expect(queryByTestId(CAMPAIGN_JOIN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull();
677+
const { getByTestId, getByText } = render(<OndoCampaignDetailsView />);
678+
expect(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeDefined();
679+
expect(getByText('Entries closed')).toBeDefined();
638680
});
639681

640682
it('does not render the CTA when campaign is complete', () => {
@@ -653,7 +695,7 @@ describe('OndoCampaignDetailsView', () => {
653695
],
654696
});
655697
const { queryByTestId } = render(<OndoCampaignDetailsView />);
656-
expect(queryByTestId(CAMPAIGN_JOIN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull();
698+
expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull();
657699
});
658700
});
659701

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

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks';
2929
import OndoLeaderboard from '../components/Campaigns/OndoLeaderboard';
3030
import OndoLeaderboardPosition from '../components/Campaigns/OndoLeaderboardPosition';
3131
import OndoPortfolio from '../components/Campaigns/OndoPortfolio';
32-
import CampaignJoinCTA from '../components/Campaigns/CampaignJoinCTA';
32+
import CampaignCTA from '../components/Campaigns/CampaignCTA';
3333
import {
3434
getCampaignStatus,
3535
isOptinAllowed,
@@ -96,15 +96,6 @@ const OndoCampaignDetailsView: React.FC = () => {
9696

9797
const isOptedIn = participantStatusData?.optedIn === true;
9898

99-
// Campaign is active but the deposit cutoff date has passed — user can no longer opt in
100-
const areEntriesClosed = useMemo(
101-
() =>
102-
campaign !== null &&
103-
getCampaignStatus(campaign) === 'active' &&
104-
!isOptinAllowed(campaign),
105-
[campaign],
106-
);
107-
10899
// Single fetch point for portfolio — data is passed to both the portfolio section and
109100
// used to gate the leaderboard rank section visibility
110101
const {
@@ -158,6 +149,13 @@ const OndoCampaignDetailsView: React.FC = () => {
158149
};
159150
}
160151

152+
const showHowItWorksSection =
153+
Boolean(campaign.details?.howItWorks) &&
154+
!hasPositions &&
155+
!isPortfolioLoading &&
156+
getCampaignStatus(campaign) === 'active' &&
157+
isOptinAllowed(campaign);
158+
161159
const showCompetitionEndedBanner =
162160
getCampaignStatus(campaign) === 'complete' ||
163161
(!isParticipantStatusLoading &&
@@ -166,38 +164,34 @@ const OndoCampaignDetailsView: React.FC = () => {
166164
(portfolioHasFetched && !hasPositions && !hasPortfolioError)));
167165

168166
const showLeaderboardPositionSection = isOptedIn && hasPositions;
167+
169168
const showPortfolioSection =
170169
isOptedIn &&
171170
(!showCompetitionEndedBanner ||
172171
(hasPositions && getCampaignStatus(campaign) === 'complete') ||
173172
isPortfolioLoading ||
174173
(hasPortfolioError && !hasPositions));
175174

176-
return {
177-
showHowItWorksSection:
178-
Boolean(campaign.details?.howItWorks) &&
179-
!isParticipantStatusLoading &&
180-
!isOptedIn &&
181-
!areEntriesClosed &&
182-
getCampaignStatus(campaign) === 'active',
175+
const showLeaderboardSection =
176+
(showCompetitionEndedBanner &&
177+
!showLeaderboardPositionSection &&
178+
!showPortfolioSection) ||
179+
(isOptedIn &&
180+
!showCompetitionEndedBanner &&
181+
!hasPositions &&
182+
!isPortfolioLoading);
183183

184+
return {
185+
showHowItWorksSection,
184186
showCompetitionEndedBanner,
185187
showLeaderboardPositionSection,
186-
showLeaderboardSection:
187-
(showCompetitionEndedBanner &&
188-
!showLeaderboardPositionSection &&
189-
!showPortfolioSection) ||
190-
(isOptedIn &&
191-
!showCompetitionEndedBanner &&
192-
!hasPositions &&
193-
!isPortfolioLoading),
188+
showLeaderboardSection,
194189
showPortfolioSection,
195190
};
196191
}, [
197192
campaign,
198193
isOptedIn,
199194
hasPositions,
200-
areEntriesClosed,
201195
isParticipantStatusLoading,
202196
isOptinClosed,
203197
portfolioHasFetched,
@@ -266,7 +260,6 @@ const OndoCampaignDetailsView: React.FC = () => {
266260
{/* Phase 1: Not opted in, show how it works section */}
267261
{showHowItWorksSection && (
268262
<>
269-
<Box twClassName="border-b border-border-muted" />
270263
<Box twClassName="px-4 py-4">
271264
<CampaignHowItWorks
272265
howItWorks={
@@ -363,6 +356,27 @@ const OndoCampaignDetailsView: React.FC = () => {
363356
<>
364357
<Box twClassName="border-b border-border-muted" />
365358
<Box twClassName="p-4">
359+
<Pressable
360+
onPress={() =>
361+
navigation.navigate(
362+
Routes.REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW,
363+
{ campaignId },
364+
)
365+
}
366+
>
367+
<Box
368+
flexDirection={BoxFlexDirection.Row}
369+
alignItems={BoxAlignItems.Center}
370+
twClassName="gap-2 mb-4"
371+
>
372+
<Text variant={TextVariant.HeadingMd}>
373+
{strings(
374+
'rewards.ondo_campaign_portfolio.positions_title',
375+
)}
376+
</Text>
377+
<Icon name={IconName.ArrowRight} size={IconSize.Md} />
378+
</Box>
379+
</Pressable>
366380
<OndoPortfolio
367381
portfolio={portfolioData}
368382
isLoading={isPortfolioLoading}
@@ -422,12 +436,13 @@ const OndoCampaignDetailsView: React.FC = () => {
422436
</ScrollView>
423437

424438
{campaign && (
425-
<CampaignJoinCTA
439+
<CampaignCTA
426440
campaign={campaign}
427441
participantStatus={{
428442
status: participantStatusData,
429443
isLoading: isParticipantStatusLoading,
430444
}}
445+
hasPositions={hasPositions}
431446
/>
432447
)}
433448
</SafeAreaView>

0 commit comments

Comments
 (0)