Skip to content

Commit 554fffd

Browse files
authored
feat(money): conditional home CTA — primary Earn / secondary Get started (MUSD-788) (#30256)
## **Description** The Money Account card on the wallet home screen showed a primary "Get started" CTA for new wallets. For a freshly created wallet the wallet-home onboarding stepper is also displayed, and that stepper owns its own primary "Add funds" CTA — resulting in two competing primary CTAs on one screen. This adds conditional logic so the Money card CTA is **secondary "Get started"** while the wallet-home onboarding stepper is displayed, and **primary "Earn"** when it is not. The `isEmpty` (seen onboarding) and funded states, the "Add" button, and the stepper itself are untouched. A new `selectInWalletHomeOnboardingFlow` selector composes the existing homepage-sections / onboarding-steps-enabled / should-show-steps selectors (same condition the stepper itself is gated on), and only the Money card's new-user branch consumes it. ## **Changelog** CHANGELOG entry: Updated the Money home screen call-to-action to show a primary "Earn" button, switching to a secondary "Get started" button while the wallet-home onboarding stepper is displayed. ## **Related issues** Fixes: [MUSD-788](https://consensyssoftware.atlassian.net/browse/MUSD-788) ## **Manual testing steps** ```gherkin Feature: Money home CTA conditional logic Scenario: fresh wallet with the wallet-home onboarding stepper on screen Given a new wallet with a $0.00 Money balance And the wallet-home onboarding stepper is displayed When the user views the wallet home screen Then the Money card shows a secondary "Get started" button And no second primary CTA competes with the stepper Scenario: $0.00 Money balance without the onboarding stepper Given a wallet with a $0.00 Money balance And the wallet-home onboarding stepper is not displayed When the user views the wallet home screen Then the Money card shows a primary "Earn" button Scenario: funded Money balance Given a wallet with a Money balance greater than $0.00 When the user views the wallet home screen Then the Money card shows the existing secondary "Add" button unchanged ``` ## **Screenshots/Recordings** ### **Before** ### **After** <img width="406" height="105" alt="image" src="https://github.com/user-attachments/assets/3d5ba682-e144-4a81-985f-4084bd9b9fcf" /> <img width="410" height="105" alt="image" src="https://github.com/user-attachments/assets/a8068d28-9549-420d-b098-c08cea0820f1" /> ## **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 - [ ] 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) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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. [MUSD-788]: https://consensyssoftware.atlassian.net/browse/MUSD-788?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI/selector change that only affects which CTA label/variant is shown for new users and adds a small selector composed from existing feature flags/onboarding state. > > **Overview** > Updates the Money balance card’s *new-user empty state* CTA to avoid competing primary actions with the wallet-home onboarding stepper: it now shows a **secondary** `Get started` when the stepper is visible, otherwise a **primary** `Earn` button. > > Adds `selectWalletHomeOnboardingFlowVisible` to gate this behavior using existing homepage/feature-flag and onboarding-step visibility checks, introduces a new `EARN_BUTTON` test ID, updates/enhances unit tests for both CTA branches, and adds the new `earn` i18n string. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 006412b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d99af47 commit 554fffd

6 files changed

Lines changed: 174 additions & 17 deletions

File tree

app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { strings } from '../../../../../../locales/i18n';
77
import Routes from '../../../../../constants/navigation/Routes';
88
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
99
import { selectMoneyOnboardingSeen } from '../../../../../reducers/user/selectors';
10+
import { selectWalletHomeOnboardingFlowVisible } from '../../../../../selectors/onboarding';
1011
import { useMoneyNavigation } from '../../hooks/useMoneyNavigation';
1112

1213
const mockNavigate = jest.fn();
@@ -37,8 +38,16 @@ jest.mock('../../../../../reducers/user/selectors', () => ({
3738
selectMoneyOnboardingSeen: jest.fn(),
3839
}));
3940

41+
jest.mock('../../../../../selectors/onboarding', () => ({
42+
__esModule: true,
43+
selectWalletHomeOnboardingFlowVisible: jest.fn(),
44+
}));
45+
4046
const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance);
4147
const mockSelectMoneyOnboardingSeen = jest.mocked(selectMoneyOnboardingSeen);
48+
const mockSelectWalletHomeOnboardingFlowVisible = jest.mocked(
49+
selectWalletHomeOnboardingFlowVisible,
50+
);
4251
const mockUseMoneyNavigation = jest.mocked(useMoneyNavigation);
4352

4453
const createBalanceMock = (
@@ -78,6 +87,7 @@ describe('MoneyBalanceCard', () => {
7887
jest.clearAllMocks();
7988
mockUseMoneyAccountBalance.mockReturnValue(createBalanceMock());
8089
mockSelectMoneyOnboardingSeen.mockReturnValue(true);
90+
mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(false);
8191
mockUseMoneyNavigation.mockReturnValue({
8292
navigateToMoneyHome: mockNavigateToMoneyHome,
8393
});
@@ -155,16 +165,6 @@ describe('MoneyBalanceCard', () => {
155165
).toBeOnTheScreen();
156166
});
157167

158-
it('renders the Get started button', () => {
159-
const { getByTestId } = renderWithProvider(<MoneyBalanceCard />);
160-
161-
expect(
162-
getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON),
163-
).toHaveTextContent(
164-
strings('homepage.sections.money_empty_state.get_started'),
165-
);
166-
});
167-
168168
it('does not render the Add button', () => {
169169
const { queryByTestId } = renderWithProvider(<MoneyBalanceCard />);
170170

@@ -173,12 +173,80 @@ describe('MoneyBalanceCard', () => {
173173
).not.toBeOnTheScreen();
174174
});
175175

176-
it('calls navigateToMoneyHome when Get started is pressed', () => {
177-
const { getByTestId } = renderWithProvider(<MoneyBalanceCard />);
176+
describe('when the wallet-home onboarding stepper is not displayed', () => {
177+
beforeEach(() => {
178+
mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(false);
179+
});
180+
181+
it('renders the Earn button with the earn label', () => {
182+
const { getByTestId, queryByTestId } = renderWithProvider(
183+
<MoneyBalanceCard />,
184+
);
185+
186+
expect(
187+
getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON),
188+
).toHaveTextContent(
189+
strings('homepage.sections.money_empty_state.earn'),
190+
);
191+
expect(
192+
queryByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON),
193+
).not.toBeOnTheScreen();
194+
});
178195

179-
fireEvent.press(getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON));
196+
it('still renders the new-user container', () => {
197+
const { getByTestId } = renderWithProvider(<MoneyBalanceCard />);
180198

181-
expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1);
199+
expect(
200+
getByTestId(MoneyBalanceCardTestIds.NEW_USER_CONTAINER),
201+
).toBeOnTheScreen();
202+
});
203+
204+
it('calls navigateToMoneyHome when Earn is pressed', () => {
205+
const { getByTestId } = renderWithProvider(<MoneyBalanceCard />);
206+
207+
fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON));
208+
209+
expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1);
210+
});
211+
});
212+
213+
describe('when the wallet-home onboarding stepper is displayed', () => {
214+
beforeEach(() => {
215+
mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(true);
216+
});
217+
218+
it('renders the Get started button with the get_started label', () => {
219+
const { getByTestId, queryByTestId } = renderWithProvider(
220+
<MoneyBalanceCard />,
221+
);
222+
223+
expect(
224+
getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON),
225+
).toHaveTextContent(
226+
strings('homepage.sections.money_empty_state.get_started'),
227+
);
228+
expect(
229+
queryByTestId(MoneyBalanceCardTestIds.EARN_BUTTON),
230+
).not.toBeOnTheScreen();
231+
});
232+
233+
it('still renders the new-user container', () => {
234+
const { getByTestId } = renderWithProvider(<MoneyBalanceCard />);
235+
236+
expect(
237+
getByTestId(MoneyBalanceCardTestIds.NEW_USER_CONTAINER),
238+
).toBeOnTheScreen();
239+
});
240+
241+
it('calls navigateToMoneyHome when Get started is pressed', () => {
242+
const { getByTestId } = renderWithProvider(<MoneyBalanceCard />);
243+
244+
fireEvent.press(
245+
getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON),
246+
);
247+
248+
expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1);
249+
});
182250
});
183251
});
184252

app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export const MoneyBalanceCardTestIds = {
1010
APY_TAG_SKELETON: 'money-balance-card-apy-tag-skeleton',
1111
ADD_BUTTON: 'money-balance-card-add-button',
1212
GET_STARTED_BUTTON: 'money-balance-card-get-started-button',
13+
EARN_BUTTON: 'money-balance-card-earn-button',
1314
} as const;

app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { strings } from '../../../../../../locales/i18n';
2626
import Routes from '../../../../../constants/navigation/Routes';
2727
import { useStyles } from '../../../../../component-library/hooks';
2828
import { selectMoneyOnboardingSeen } from '../../../../../reducers/user/selectors';
29+
import { selectWalletHomeOnboardingFlowVisible } from '../../../../../selectors/onboarding';
2930
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
3031
import styleSheet from './MoneyBalanceCard.styles';
3132
import { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds';
@@ -46,6 +47,9 @@ const MoneyBalanceCard = () => {
4647
} = useMoneyAccountBalance();
4748
const { navigateToMoneyHome } = useMoneyNavigation();
4849
const hasSeenMoneyOnboarding = useSelector(selectMoneyOnboardingSeen);
50+
const walletHomeOnboardingFlowVisible = useSelector(
51+
selectWalletHomeOnboardingFlowVisible,
52+
);
4953

5054
const isEmpty = totalFiatRaw === undefined || totalFiatRaw === '0';
5155
const isNewUser = isEmpty && !hasSeenMoneyOnboarding;
@@ -57,10 +61,16 @@ const MoneyBalanceCard = () => {
5761
let containerTestId: string;
5862
if (isNewUser) {
5963
balanceText = EMPTY_BALANCE_DISPLAY;
60-
buttonVariant = ButtonVariant.Primary;
61-
buttonLabel = strings('homepage.sections.money_empty_state.get_started');
62-
buttonTestId = MoneyBalanceCardTestIds.GET_STARTED_BUTTON;
6364
containerTestId = MoneyBalanceCardTestIds.NEW_USER_CONTAINER;
65+
if (walletHomeOnboardingFlowVisible) {
66+
buttonVariant = ButtonVariant.Secondary;
67+
buttonLabel = strings('homepage.sections.money_empty_state.get_started');
68+
buttonTestId = MoneyBalanceCardTestIds.GET_STARTED_BUTTON;
69+
} else {
70+
buttonVariant = ButtonVariant.Primary;
71+
buttonLabel = strings('homepage.sections.money_empty_state.earn');
72+
buttonTestId = MoneyBalanceCardTestIds.EARN_BUTTON;
73+
}
6474
} else if (isEmpty) {
6575
balanceText = EMPTY_BALANCE_DISPLAY;
6676
buttonVariant = ButtonVariant.Primary;

app/selectors/onboarding/index.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,27 @@ import {
55
selectWalletHomeOnboardingSteps,
66
selectWalletHomeOnboardingStepsEligible,
77
selectShouldShowWalletHomeOnboardingSteps,
8+
selectWalletHomeOnboardingFlowVisible,
89
} from '.';
910
import { RootState } from '../../reducers';
1011
import { AccountType } from '../../constants/onboarding';
1112
import { WALLET_HOME_ONBOARDING_STEPS_INITIAL } from '../../constants/walletHomeOnboardingSteps';
13+
import {
14+
selectHomepageSectionsV1Enabled,
15+
selectWalletHomeOnboardingStepsEnabled,
16+
} from '../featureFlagController/homepage';
17+
18+
jest.mock('../featureFlagController/homepage', () => ({
19+
selectHomepageSectionsV1Enabled: jest.fn(),
20+
selectWalletHomeOnboardingStepsEnabled: jest.fn(),
21+
}));
22+
23+
const mockSelectHomepageSectionsV1Enabled = jest.mocked(
24+
selectHomepageSectionsV1Enabled,
25+
);
26+
const mockSelectWalletHomeOnboardingStepsEnabled = jest.mocked(
27+
selectWalletHomeOnboardingStepsEnabled,
28+
);
1229

1330
describe('Onboarding selectors', () => {
1431
const mockState = {
@@ -89,4 +106,53 @@ describe('Onboarding selectors', () => {
89106
).toBe(false);
90107
});
91108
});
109+
110+
describe('selectWalletHomeOnboardingFlowVisible', () => {
111+
// shouldShow comes from selectShouldShowWalletHomeOnboardingSteps:
112+
// eligible && steps.suppressedReason === null.
113+
const stateWithShouldShow = (shouldShow: boolean) =>
114+
({
115+
onboarding: {
116+
walletHomeOnboardingStepsEligible: shouldShow,
117+
walletHomeOnboardingSteps: WALLET_HOME_ONBOARDING_STEPS_INITIAL,
118+
},
119+
}) as RootState;
120+
121+
beforeEach(() => {
122+
mockSelectHomepageSectionsV1Enabled.mockReset();
123+
mockSelectWalletHomeOnboardingStepsEnabled.mockReset();
124+
});
125+
126+
it('is true when all three inputs are true', () => {
127+
mockSelectHomepageSectionsV1Enabled.mockReturnValue(true);
128+
mockSelectWalletHomeOnboardingStepsEnabled.mockReturnValue(true);
129+
expect(
130+
selectWalletHomeOnboardingFlowVisible(stateWithShouldShow(true)),
131+
).toBe(true);
132+
});
133+
134+
it('is false when sectionsV1 is false', () => {
135+
mockSelectHomepageSectionsV1Enabled.mockReturnValue(false);
136+
mockSelectWalletHomeOnboardingStepsEnabled.mockReturnValue(true);
137+
expect(
138+
selectWalletHomeOnboardingFlowVisible(stateWithShouldShow(true)),
139+
).toBe(false);
140+
});
141+
142+
it('is false when stepsEnabled is false', () => {
143+
mockSelectHomepageSectionsV1Enabled.mockReturnValue(true);
144+
mockSelectWalletHomeOnboardingStepsEnabled.mockReturnValue(false);
145+
expect(
146+
selectWalletHomeOnboardingFlowVisible(stateWithShouldShow(true)),
147+
).toBe(false);
148+
});
149+
150+
it('is false when shouldShow is false', () => {
151+
mockSelectHomepageSectionsV1Enabled.mockReturnValue(true);
152+
mockSelectWalletHomeOnboardingStepsEnabled.mockReturnValue(true);
153+
expect(
154+
selectWalletHomeOnboardingFlowVisible(stateWithShouldShow(false)),
155+
).toBe(false);
156+
});
157+
});
92158
});

app/selectors/onboarding/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { RootState } from '../../reducers';
22
import { createSelector } from 'reselect';
33
import { WALLET_HOME_ONBOARDING_STEPS_INITIAL } from '../../constants/walletHomeOnboardingSteps';
4+
import {
5+
selectHomepageSectionsV1Enabled,
6+
selectWalletHomeOnboardingStepsEnabled,
7+
} from '../featureFlagController/homepage';
48

59
const selectOnboarding = (state: RootState) => state.onboarding;
610

@@ -50,3 +54,10 @@ export const selectShouldShowWalletHomeOnboardingSteps = createSelector(
5054
selectWalletHomeOnboardingSteps,
5155
(eligible, steps) => eligible && steps?.suppressedReason === null,
5256
);
57+
58+
export const selectWalletHomeOnboardingFlowVisible = (
59+
state: RootState,
60+
): boolean =>
61+
selectHomepageSectionsV1Enabled(state) &&
62+
selectWalletHomeOnboardingStepsEnabled(state) &&
63+
selectShouldShowWalletHomeOnboardingSteps(state);

locales/languages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9207,6 +9207,7 @@
92079207
"money_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.",
92089208
"money_empty_state": {
92099209
"get_started": "Get started",
9210+
"earn": "Earn",
92109211
"earn_apy": "Earn {{percentage}}% APY"
92119212
},
92129213
"money_filled_state": {

0 commit comments

Comments
 (0)