Skip to content

Commit 2c34efa

Browse files
authored
feat: MUSD-776 create money account onboarding flow with rive animation (#30137)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** This PR creates the Money account onboarding flow and wires it up for first time users. ### Changes - Created reusable highly-configurable `<RiveOnboardingStepper/>` to support future onboarding flows. - Created `MoneyOnboardingView` which uses `<RiveOnboardingStepper/>` - Wired up redirects to the `MoneyOnboardingView` for first time users when: - Pressing the "Money" navbar button - Pressing the home screen Money balance card - Pressing the home screen Money balance card's "Get started" button - Added Money onboarding reset button to developer options menu to reset state <!-- 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: added Money account onboarding flow ## **Related issues** Fixes: [MUSD-776: Create Money Account Onboarding with Rive Animation](https://consensyssoftware.atlassian.net/browse/MUSD-776) ## **Manual testing steps** ```gherkin Feature: Money onboarding redirect Scenario: user navigates to Money via the Money navbar button Given user has not previously seen the Money onboarding And user is on the home screen When user taps the Money navbar item Then user is navigated to the Money onboarding screen And the animated onboarding stepper plays through its steps And when completed, the Money onboarding seen flag is set to true And user is navigated to the Money home screen Scenario: user navigates to Money via the MoneyBalanceCard Given user has not previously seen the Money onboarding And user is on the wallet home screen with the MoneyBalanceCard visible When user taps the "Get Started" button on the MoneyBalanceCard Then user is navigated to the Money onboarding screen And the animated onboarding stepper plays through its steps And when completed, the Money onboarding seen flag is set to true And user is navigated to the Money home screen ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> N/A - Onboarding flow didn't exist ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/3e799c15-409a-4dc6-b755-b7ef58b1cad2 ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] 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 multi-step onboarding screen and changes Money entry navigation to conditionally redirect based on a new persisted `moneyOnboardingSeen` flag, which could affect user routing if mis-set. Risk is mitigated by reducer/selector coverage and component-level tests but still touches core navigation paths (TabBar, Home, Money card). > > **Overview** > Introduces a new Money account onboarding flow (`Routes.MONEY.ONBOARDING`) backed by a reusable `RiveOnboardingStepper`, including progress UI, step timing, close handling, and optional auto-complete on the final step. > > Adds a new persisted user flag (`moneyOnboardingSeen`) with action/reducer/selector plumbing, and updates Money entry points (TabBar Money tab and `MoneyBalanceCard`) to use a shared `useMoneyNavigation.navigateToMoneyHome()` redirect that sends first-time users to onboarding before allowing navigation to Money home. > > Extends Developer Options (behind the Money feature flag) with a Money UI section to reset onboarding state and copy the primary money account address, and adds i18n strings plus targeted tests for the new components and navigation behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 381fd23. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent fc488ec commit 2c34efa

30 files changed

Lines changed: 1811 additions & 45 deletions

app/actions/user/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
type SetMusdConversionEducationSeenAction,
2727
type SetMusdConversionAssetDetailCtaSeenAction,
2828
type ClearMusdConversionAssetDetailCtasSeenAction,
29+
type SetMoneyOnboardingSeenAction,
2930
type SetTokenOverviewChartTypeAction,
3031
UserActionType,
3132
} from './types';
@@ -226,6 +227,18 @@ export function clearMusdConversionAssetDetailCtasSeen(): ClearMusdConversionAss
226227
};
227228
}
228229

230+
/**
231+
* Action to set Money onboarding as seen
232+
*/
233+
export function setMoneyOnboardingSeen(
234+
seen: boolean,
235+
): SetMoneyOnboardingSeenAction {
236+
return {
237+
type: UserActionType.SET_MONEY_ONBOARDING_SEEN,
238+
payload: { seen },
239+
};
240+
}
241+
229242
/**
230243
* Action to set token overview chart type preference
231244
*/

app/actions/user/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export enum UserActionType {
2929
SET_MUSD_CONVERSION_EDUCATION_SEEN = 'SET_MUSD_CONVERSION_EDUCATION_SEEN',
3030
SET_MUSD_CONVERSION_ASSET_DETAIL_CTA_SEEN = 'SET_MUSD_CONVERSION_ASSET_DETAIL_CTA_SEEN',
3131
CLEAR_MUSD_CONVERSION_ASSET_DETAIL_CTAS_SEEN = 'CLEAR_MUSD_CONVERSION_ASSET_DETAIL_CTAS_SEEN',
32+
SET_MONEY_ONBOARDING_SEEN = 'SET_MONEY_ONBOARDING_SEEN',
3233
SET_TOKEN_OVERVIEW_CHART_TYPE = 'SET_TOKEN_OVERVIEW_CHART_TYPE',
3334
}
3435

@@ -113,6 +114,11 @@ export type SetMusdConversionAssetDetailCtaSeenAction =
113114
export type ClearMusdConversionAssetDetailCtasSeenAction =
114115
Action<UserActionType.CLEAR_MUSD_CONVERSION_ASSET_DETAIL_CTAS_SEEN>;
115116

117+
export type SetMoneyOnboardingSeenAction =
118+
Action<UserActionType.SET_MONEY_ONBOARDING_SEEN> & {
119+
payload: { seen: boolean };
120+
};
121+
116122
export type SetTokenOverviewChartTypeAction =
117123
Action<UserActionType.SET_TOKEN_OVERVIEW_CHART_TYPE> & {
118124
payload: { chartType: ChartType };
@@ -147,4 +153,5 @@ export type UserAction =
147153
| SetMusdConversionEducationSeenAction
148154
| SetMusdConversionAssetDetailCtaSeenAction
149155
| ClearMusdConversionAssetDetailCtasSeenAction
156+
| SetMoneyOnboardingSeenAction
150157
| SetTokenOverviewChartTypeAction;
5.38 MB
Binary file not shown.

app/component-library/components/Navigation/TabBar/TabBar.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
LABEL_BY_TAB_BAR_ICON_KEY,
2929
} from './TabBar.constants';
3030
import { selectChainId } from '../../../../selectors/networkController';
31+
import { useMoneyNavigation } from '../../../../components/UI/Money/hooks/useMoneyNavigation';
3132

3233
const FILLED_ICONS: Partial<Record<TabBarIconKey, IconName>> = {
3334
[TabBarIconKey.Wallet]: IconName.HomeFilled,
@@ -44,6 +45,7 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => {
4445
const tabBarRef = useRef(null);
4546
const previousTabIndexRef = useRef<number>(state.index);
4647
const tw = useTailwind();
48+
const { navigateToMoneyHome } = useMoneyNavigation();
4749

4850
const renderTabBarItem = useCallback(
4951
(route: { name: string; key: string }, index: number) => {
@@ -111,11 +113,10 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => {
111113
case Routes.TRENDING_VIEW:
112114
navigation.navigate(Routes.TRENDING_VIEW);
113115
break;
114-
case Routes.MONEY.HOME:
115-
navigation.navigate(Routes.MONEY.ROOT, {
116-
screen: Routes.MONEY.HOME,
117-
});
116+
case Routes.MONEY.HOME: {
117+
navigateToMoneyHome();
118118
break;
119+
}
119120
}
120121
};
121122

@@ -140,13 +141,16 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => {
140141
);
141142
},
142143
[
143-
state,
144144
descriptors,
145+
state.routeNames,
146+
state.index,
147+
state.routes,
148+
tw,
145149
navigation,
146-
chainId,
147150
trackEvent,
148151
createEventBuilder,
149-
tw,
152+
chainId,
153+
navigateToMoneyHome,
150154
],
151155
);
152156

app/components/Nav/Main/MainNavigator.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import { StakeModalStack, StakeScreenStack } from '../../UI/Stake/routes';
103103
import { AssetLoader } from '../../Views/AssetLoader';
104104
import { EarnScreenStack, EarnModalStack } from '../../UI/Earn/routes';
105105
import { MoneyAccountStackGate, MoneyModalStack } from '../../UI/Money/routes';
106+
import MoneyOnboardingView from '../../UI/Money/Views/MoneyOnboardingView';
106107
import { selectMoneyHomeScreenEnabledFlag } from '../../UI/Money/selectors/featureFlags';
107108
import { BridgeTransactionDetails } from '../../UI/Bridge/components/TransactionDetails/TransactionDetails';
108109
import { BridgeModalStack, BridgeScreenStack } from '../../UI/Bridge/routes';
@@ -1228,6 +1229,11 @@ const MainNavigator = () => {
12281229
component={MoneyAccountStackGate}
12291230
options={{ headerShown: false, ...slideFromRightAnimation }}
12301231
/>
1232+
<Stack.Screen
1233+
name={Routes.MONEY.ONBOARDING}
1234+
component={MoneyOnboardingView}
1235+
options={{ headerShown: false }}
1236+
/>
12311237
<Stack.Screen
12321238
name={Routes.MONEY.MODALS.ROOT}
12331239
component={MoneyModalStack}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import React from 'react';
2+
import { render, fireEvent, act } from '@testing-library/react-native';
3+
import MoneyOnboardingView, {
4+
MONEY_ONBOARDING_STEP_DURATION_MS,
5+
} from './MoneyOnboardingView';
6+
import { RiveOnboardingStepperTestIds } from '../../../RiveOnboardingStepper/RiveOnboardingStepper.testIds';
7+
import { __clearLastMockedMethods } from '../../../../../__mocks__/rive-react-native';
8+
import Routes from '../../../../../constants/navigation/Routes';
9+
10+
const mockGoBack = jest.fn();
11+
const mockNavigate = jest.fn();
12+
const mockDispatch = jest.fn();
13+
14+
jest.mock('@react-navigation/native', () => ({
15+
useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }),
16+
}));
17+
18+
jest.mock('react-redux', () => ({
19+
useDispatch: () => mockDispatch,
20+
useSelector: jest.fn().mockReturnValue(false),
21+
}));
22+
23+
jest.mock('../../hooks/useMoneyAccountBalance', () => ({
24+
__esModule: true,
25+
default: () => ({ apyPercent: 4 }),
26+
}));
27+
28+
jest.mock('react-native-linear-gradient', () => 'LinearGradient');
29+
30+
jest.mock('react-native-reanimated', () => {
31+
// eslint-disable-next-line @typescript-eslint/no-require-imports
32+
const Reanimated = require('react-native-reanimated/mock');
33+
Reanimated.default.call = jest.fn();
34+
return Reanimated;
35+
});
36+
37+
jest.mock(
38+
'../../../../../animations/money_account_onboarding_animation.riv',
39+
() => 1,
40+
{ virtual: true },
41+
);
42+
43+
describe('MoneyOnboardingView', () => {
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
__clearLastMockedMethods();
47+
});
48+
49+
describe('Rendering', () => {
50+
it('renders the onboarding stepper container', () => {
51+
const { getByTestId } = render(<MoneyOnboardingView />);
52+
expect(
53+
getByTestId(RiveOnboardingStepperTestIds.CONTAINER),
54+
).toBeOnTheScreen();
55+
});
56+
57+
it('renders the progress bar', () => {
58+
const { getByTestId } = render(<MoneyOnboardingView />);
59+
expect(
60+
getByTestId(RiveOnboardingStepperTestIds.PROGRESS_BAR),
61+
).toBeOnTheScreen();
62+
});
63+
64+
it('renders five progress segments', () => {
65+
const { getByTestId } = render(<MoneyOnboardingView />);
66+
[0, 1, 2, 3, 4].forEach((index) => {
67+
expect(
68+
getByTestId(
69+
`${RiveOnboardingStepperTestIds.PROGRESS_SEGMENT}-${index}`,
70+
),
71+
).toBeOnTheScreen();
72+
});
73+
});
74+
75+
it('renders the Rive animation', () => {
76+
const { getByTestId } = render(<MoneyOnboardingView />);
77+
expect(
78+
getByTestId(RiveOnboardingStepperTestIds.RIVE_ANIMATION),
79+
).toBeOnTheScreen();
80+
});
81+
82+
it('renders the footer button', () => {
83+
const { getByTestId } = render(<MoneyOnboardingView />);
84+
expect(
85+
getByTestId(RiveOnboardingStepperTestIds.FOOTER_BUTTON),
86+
).toBeOnTheScreen();
87+
});
88+
89+
it('renders the close button', () => {
90+
const { getByTestId } = render(<MoneyOnboardingView />);
91+
expect(
92+
getByTestId(RiveOnboardingStepperTestIds.CLOSE_BUTTON),
93+
).toBeOnTheScreen();
94+
});
95+
96+
it('renders the first step title', () => {
97+
const { getByText } = render(<MoneyOnboardingView />);
98+
expect(getByText('Money accounts are here')).toBeOnTheScreen();
99+
});
100+
101+
it('renders the first step body text', () => {
102+
const { getByText } = render(<MoneyOnboardingView />);
103+
expect(
104+
getByText(
105+
'Earn up to 4% APY on your balance, available across your entire wallet.',
106+
),
107+
).toBeOnTheScreen();
108+
});
109+
110+
it('renders the first step footer text', () => {
111+
const { getByText } = render(<MoneyOnboardingView />);
112+
expect(
113+
getByText('APY is variable and may change at any time.'),
114+
).toBeOnTheScreen();
115+
});
116+
});
117+
118+
describe('Navigation', () => {
119+
it('calls navigation.goBack when close button is pressed', () => {
120+
const { getByTestId } = render(<MoneyOnboardingView />);
121+
fireEvent.press(getByTestId(RiveOnboardingStepperTestIds.CLOSE_BUTTON));
122+
expect(mockGoBack).toHaveBeenCalledTimes(1);
123+
});
124+
125+
it('dispatches setMoneyOnboardingSeen and navigates to Money home on completion', () => {
126+
jest.useFakeTimers();
127+
const { getByTestId } = render(<MoneyOnboardingView />);
128+
const footerButton = getByTestId(
129+
RiveOnboardingStepperTestIds.FOOTER_BUTTON,
130+
);
131+
132+
// Step 0 -> 1 (button starts enabled at step 0)
133+
fireEvent.press(footerButton);
134+
act(() => jest.advanceTimersByTime(MONEY_ONBOARDING_STEP_DURATION_MS));
135+
136+
// Step 1 -> 2
137+
fireEvent.press(footerButton);
138+
act(() => jest.advanceTimersByTime(MONEY_ONBOARDING_STEP_DURATION_MS));
139+
140+
// Step 2 -> 3
141+
fireEvent.press(footerButton);
142+
act(() => jest.advanceTimersByTime(MONEY_ONBOARDING_STEP_DURATION_MS));
143+
144+
// Step 3 -> 4 (last step, autoComplete timer starts)
145+
fireEvent.press(footerButton);
146+
147+
// Auto-complete timer fires onComplete after durationMs
148+
act(() => jest.advanceTimersByTime(MONEY_ONBOARDING_STEP_DURATION_MS));
149+
150+
expect(mockDispatch).toHaveBeenCalledWith(
151+
expect.objectContaining({ type: 'SET_MONEY_ONBOARDING_SEEN' }),
152+
);
153+
expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, {
154+
screen: Routes.MONEY.ROOT,
155+
params: { screen: Routes.MONEY.HOME },
156+
});
157+
158+
jest.useRealTimers();
159+
});
160+
161+
it('does not navigate to Money home when continuing between non-final steps', () => {
162+
const { getByTestId } = render(<MoneyOnboardingView />);
163+
fireEvent.press(getByTestId(RiveOnboardingStepperTestIds.FOOTER_BUTTON));
164+
expect(mockNavigate).not.toHaveBeenCalled();
165+
});
166+
});
167+
});

0 commit comments

Comments
 (0)