Skip to content

Commit 8990394

Browse files
VGR-GITsophieqguclaude
authored
feat(rewards): add campaign opt-in flow with details, mechanics, and how-it-works views (#27619)
## Summary - Adds `CampaignOptInSheet`, `CampaignHowItWorks`, `CampaignDetailsView`, and `CampaignMechanicsView` components for the campaign opt-in flow - Wires new routes (`CampaignDetails`, `CampaignMechanics`) into `RewardsNavigator` - Updates `CampaignTile` and `CampaignsPreview` to support opt-in navigation - Extends `RewardsController` and `rewards-data-service` with `CampaignParticipantStatus` handling and revert logic when feature flag is off - Adds i18n strings for all new UI ## **Changelog** CHANGELOG entry: Added campaign opt-in flow with details and mechanics screens in the Rewards section ## Test plan - [ ] All 6 test suites pass (80 tests) - [ ] `yarn format:check` passes - [ ] ESLint: 0 errors on staged files 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new Rewards campaign navigation/screens and changes campaign opt-in API/controller behavior (including new cache/event logic and 409 handling), which could affect user enrollment state and campaign UI across the app. > > **Overview** > Adds a new in-app **campaign opt-in flow** in Rewards, including `CampaignDetailsView` (status/how-it-works + “Join campaign” CTA and opt-in bottom sheet) and `CampaignMechanicsView` (expanded “how it works” + parsed notes), plus a reusable `CampaignHowItWorks` renderer. > > Wires new routes (`CAMPAIGN_DETAILS`, `CAMPAIGN_MECHANICS`) into `RewardsNavigator`, updates `CampaignTile` to navigate to details and show an **“Entered”** state based on participant status, and enhances `CampaignsPreview` with loading skeleton/spinner and retryable error banner. > > Updates backend plumbing for opt-in: `rewards-data-service` treats HTTP `409` on opt-in as “already opted in” by fetching participant status, and `RewardsController.optInToCampaign` only emits `campaignOptedIn` when transitioning from not-opted-in to opted-in (reducing redundant refetches), with a small cache-write refactor for campaigns. Adds corresponding tests and new i18n strings. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 11ee14a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: sophieqgu <sophieqgu@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 285da87 commit 8990394

18 files changed

Lines changed: 1992 additions & 70 deletions

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,30 @@ jest.mock('./Views/RewardsSettingsView', () => {
6969
};
7070
});
7171

72+
jest.mock('./Views/CampaignDetailsView', () => {
73+
const ReactActual = jest.requireActual('react');
74+
const { View, Text } = jest.requireActual('react-native');
75+
return function MockCampaignDetailsView() {
76+
return ReactActual.createElement(
77+
View,
78+
{ testID: 'campaign-details-view' },
79+
ReactActual.createElement(Text, null, 'Campaign Details View'),
80+
);
81+
};
82+
});
83+
84+
jest.mock('./Views/CampaignMechanicsView', () => {
85+
const ReactActual = jest.requireActual('react');
86+
const { View, Text } = jest.requireActual('react-native');
87+
return function MockCampaignMechanicsView() {
88+
return ReactActual.createElement(
89+
View,
90+
{ testID: 'campaign-mechanics-view' },
91+
ReactActual.createElement(Text, null, 'Campaign Mechanics View'),
92+
);
93+
};
94+
});
95+
7296
// Mock Skeleton component
7397
jest.mock(
7498
'../../../component-library/components-temp/Skeleton/Skeleton',
@@ -405,6 +429,19 @@ describe('RewardsNavigator', () => {
405429
expect(queryByTestId('rewards-dashboard-view')).toBeNull();
406430
});
407431
});
432+
433+
it('registers CAMPAIGN_DETAILS and CAMPAIGN_MECHANICS routes when subscription exists', async () => {
434+
// Both views are registered inside the subscriptionId-guarded block,
435+
// so they are present in the navigator only when the user is enrolled.
436+
mockSelectRewardsSubscriptionId.mockReturnValue('test-subscription-id');
437+
438+
// Rendering should not throw even with the new screens registered
439+
const { getByTestId } = renderWithNavigation(<RewardsNavigator />);
440+
441+
await waitFor(() => {
442+
expect(getByTestId('rewards-dashboard-view')).toBeOnTheScreen();
443+
});
444+
});
408445
});
409446

410447
// Note: Removed AuthErrorView tests as they don't match the actual implementation

app/components/UI/Rewards/RewardsNavigator.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import RewardsDashboard from './Views/RewardsDashboard';
66
import ReferralRewardsView from './Views/RewardsReferralView';
77
import RewardsSettingsView from './Views/RewardsSettingsView';
88
import CampaignsView from './Views/CampaignsView';
9+
import CampaignDetailsView from './Views/CampaignDetailsView';
10+
import CampaignMechanicsView from './Views/CampaignMechanicsView';
911
import PreviousSeasonView from './Views/PreviousSeasonView';
1012
import { useSelector } from 'react-redux';
1113
import { selectRewardsSubscriptionId } from '../../../selectors/rewards';
1214
import { useCandidateSubscriptionId } from './hooks/useCandidateSubscriptionId';
1315
import { useNavigation } from '@react-navigation/native';
1416
import { useSeasonStatus } from './hooks/useSeasonStatus';
1517
import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata';
16-
import { useRewardCampaigns } from './hooks/useRewardCampaigns';
1718
const Stack = createStackNavigator();
1819

1920
const RewardsNavigator: React.FC = () => {
@@ -29,9 +30,6 @@ const RewardsNavigator: React.FC = () => {
2930
// Fetch geo rewards metadata so optinAllowedForGeo is available across all rewards screens
3031
useGeoRewardsMetadata({});
3132

32-
// Fetch all campaigns
33-
useRewardCampaigns();
34-
3533
// Determine initial route - always start with onboarding intro step initially
3634
const getInitialRoute = () => {
3735
// If user has already opted in and has a valid subscription candidate ID, go to dashboard
@@ -85,6 +83,16 @@ const RewardsNavigator: React.FC = () => {
8583
component={PreviousSeasonView}
8684
options={{ headerShown: false }}
8785
/>
86+
<Stack.Screen
87+
name={Routes.CAMPAIGN_DETAILS}
88+
component={CampaignDetailsView}
89+
options={{ headerShown: false }}
90+
/>
91+
<Stack.Screen
92+
name={Routes.CAMPAIGN_MECHANICS}
93+
component={CampaignMechanicsView}
94+
options={{ headerShown: false }}
95+
/>
8896
</>
8997
) : null}
9098
</Stack.Navigator>

0 commit comments

Comments
 (0)