Skip to content

Commit 592b39a

Browse files
authored
feat(money): card linking homepage state (MUSD-609) (#29177)
## **Description** Implements the Money Account homepage "card linking" state — displayed when a user has deposited funds and has an unlinked MetaMask Card. This state encourages card linking with benefits messaging. **Subtasks implemented:** - **MUSD-610**: Updated the "Step 2 of 2" `MoneyOnboardingCard` with a `variant` prop (`'get-card'` | `'link-card'`). The link-card variant shows "Link your MetaMask Card" title, cashback description, benefits bullets (checkmark icons with cashback and APY text), and a "Link card" CTA button. - **MUSD-611**: Added a `mode` prop (`'upsell'` | `'link'`) to `MoneyMetaMaskCard`. Link mode replaces the virtual/metal card rows with a card-linking layout: metal card thumbnail, benefits bullets, and a full-width "Link card" button. - **MUSD-612**: Wired the card-unlinked state in `MoneyHomeView` using the `selectIsCardholder` Redux selector. When `isMilestone && isCardholder`, both `MoneyOnboardingCard` and `MoneyMetaMaskCard` switch to their link-card variants. Verified all 6 reused sections (balance, action pills, earnings, activity, condensed cards, potential earnings) render correctly in the new state. **Card linking CTA handlers** are currently stubbed with `displayUnderConstructionAlert` — they will be wired to the card linking flow when MUSD-489 lands. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MUSD-602 ## **Manual testing steps** ```gherkin Feature: Money Account card linking homepage state Scenario: user sees card linking prompt when they have an unlinked card Given user has a Money Account with balance > $0.00 And user has ordered/received a MetaMask Card (isCardholder = true) And card is NOT linked to Money Account When user navigates to Money home screen Then the "Step 2 of 2" card shows "Link your MetaMask Card" title And benefits bullets show "Get 1-3% cashback" and "Earn 4% APY on your balance" And CTA button reads "Link card" And MetaMask Card section shows card linking layout instead of virtual/metal card rows And all other sections (balance, earnings, activity, condensed cards) display correctly Scenario: user without a card sees standard milestone state Given user has a Money Account with balance > $0.00 And user does NOT have a MetaMask Card (isCardholder = false) When user navigates to Money home screen Then the "Step 2 of 2" card shows "Get your MetaMask Card" title And MetaMask Card section shows virtual/metal card rows with "Get now" buttons ``` ## **Screenshots/Recordings** <img width="1206" height="2622" alt="image" src="https://github.com/user-attachments/assets/66162bfd-36db-4c4a-b2c1-0bc12884ad4d" /> <img width="1206" height="2622" alt="image" src="https://github.com/user-attachments/assets/79b1b757-571e-4ae6-bda7-b49243cae31e" /> ## **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 - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI/state changes: new rendering variants and CTA routing based on `selectIsCardholder`, with handlers still stubbed to under-construction alerts. > > **Overview** > Adds a new **“card-unlinked”** homepage state on `MoneyHomeView` when the user is in a milestone/filled state and `selectIsCardholder` is true, switching the onboarding CTA to a **link-card** flow. > > Extends `MoneyOnboardingCard` with a `variant` to swap step-2 copy/CTA for card linking, and extends `MoneyMetaMaskCard` with a `mode="link"` layout (benefit bullets + “Link card” button, plus APY display) instead of the virtual/metal upsell rows. > > Updates English i18n strings and expands unit tests to cover the new variants/mode and CTA press behavior across empty/milestone/card-unlinked states. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0a8d8ea. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b760c09 commit 592b39a

8 files changed

Lines changed: 468 additions & 30 deletions

File tree

app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import { MoneyActivityListTestIds } from '../../components/MoneyActivityList/Mon
1717
import { MoneyCondensedInfoCardsTestIds } from '../../components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.testIds';
1818
import Routes from '../../../../../constants/navigation/Routes';
1919
import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions';
20+
import { strings } from '../../../../../../locales/i18n';
2021
import MOCK_MONEY_TRANSACTIONS from '../../constants/mockActivityData';
2122
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
23+
import { selectIsCardholder } from '../../../../../selectors/cardController';
2224

2325
const mockGoBack = jest.fn();
2426
const mockNavigate = jest.fn();
@@ -62,6 +64,13 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({
6264
default: jest.fn(),
6365
}));
6466

67+
jest.mock('../../../../../selectors/cardController', () => ({
68+
...jest.requireActual('../../../../../selectors/cardController'),
69+
selectIsCardholder: jest.fn(),
70+
}));
71+
72+
const mockSelectIsCardholder = jest.mocked(selectIsCardholder);
73+
6574
const mockUseMoneyAccountTransactions = jest.mocked(
6675
useMoneyAccountTransactions,
6776
);
@@ -106,6 +115,9 @@ jest.mock('../../../../UI/AssetOverview/Balance/Balance', () => ({
106115
describe('MoneyHomeView', () => {
107116
beforeEach(() => {
108117
jest.clearAllMocks();
118+
global.alert = jest.fn();
119+
120+
mockSelectIsCardholder.mockReturnValue(false);
109121

110122
mockUseMoneyAccountBalance.mockReturnValue({
111123
totalFiatFormatted: '$3.00',
@@ -292,6 +304,115 @@ describe('MoneyHomeView', () => {
292304
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
293305
expect(getByTestId(MoneyMetaMaskCardTestIds.CONTAINER)).toBeOnTheScreen();
294306
});
307+
308+
it('fires handleCardPress when onboarding CTA is tapped', () => {
309+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
310+
expect(() => {
311+
fireEvent.press(getByTestId(MoneyOnboardingCardTestIds.CTA_BUTTON));
312+
}).not.toThrow();
313+
});
314+
});
315+
316+
describe('card-unlinked state (milestone + has cardholder)', () => {
317+
beforeEach(() => {
318+
mockUseMoneyAccountTransactions.mockReturnValue({
319+
allTransactions: Array.from({ length: 3 }, (_, index) => ({
320+
...MOCK_MONEY_TRANSACTIONS[index % MOCK_MONEY_TRANSACTIONS.length],
321+
id: `card-unlinked-${index}`,
322+
})),
323+
deposits: [],
324+
transfers: [],
325+
submittedTransactions: [],
326+
moneyAddress: '0x0000000000000000000000000000000000000001',
327+
});
328+
mockSelectIsCardholder.mockReturnValue(true);
329+
});
330+
331+
it('renders onboarding card with step 2 and link-card variant', () => {
332+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
333+
expect(
334+
getByTestId(MoneyOnboardingCardTestIds.STEP_LABEL),
335+
).toHaveTextContent('Step 2 of 2');
336+
expect(getByTestId(MoneyOnboardingCardTestIds.TITLE)).toHaveTextContent(
337+
strings('money.onboarding.link_card_title'),
338+
);
339+
});
340+
341+
it('renders MetaMask Card section in link mode', () => {
342+
const { getByTestId, queryByTestId } = renderWithProvider(
343+
<MoneyHomeView />,
344+
);
345+
expect(
346+
getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON),
347+
).toBeOnTheScreen();
348+
expect(
349+
queryByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW),
350+
).not.toBeOnTheScreen();
351+
});
352+
353+
it('renders the balance summary section', () => {
354+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
355+
expect(
356+
getByTestId(MoneyBalanceSummaryTestIds.CONTAINER),
357+
).toBeOnTheScreen();
358+
});
359+
360+
it('renders the action button row', () => {
361+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
362+
expect(
363+
getByTestId(MoneyActionButtonRowTestIds.CONTAINER),
364+
).toBeOnTheScreen();
365+
});
366+
367+
it('renders the earnings section', () => {
368+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
369+
expect(getByTestId(MoneyEarningsTestIds.CONTAINER)).toBeOnTheScreen();
370+
});
371+
372+
it('renders the activity list', () => {
373+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
374+
expect(getByTestId(MoneyActivityListTestIds.CONTAINER)).toBeOnTheScreen();
375+
});
376+
377+
it('renders condensed info cards', () => {
378+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
379+
expect(
380+
getByTestId(MoneyCondensedInfoCardsTestIds.CONTAINER),
381+
).toBeOnTheScreen();
382+
});
383+
384+
it('renders the potential earnings section', () => {
385+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
386+
expect(
387+
getByTestId(MoneyPotentialEarningsTestIds.CONTAINER),
388+
).toBeOnTheScreen();
389+
});
390+
391+
it('hides expanded HowItWorks section', () => {
392+
const { queryByTestId } = renderWithProvider(<MoneyHomeView />);
393+
expect(
394+
queryByTestId(MoneyHowItWorksTestIds.CONTAINER),
395+
).not.toBeOnTheScreen();
396+
});
397+
398+
it('hides expanded WhatYouGet section', () => {
399+
const { queryByTestId } = renderWithProvider(<MoneyHomeView />);
400+
expect(
401+
queryByTestId(MoneyWhatYouGetTestIds.CONTAINER),
402+
).not.toBeOnTheScreen();
403+
});
404+
405+
it('renders the footer', () => {
406+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
407+
expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen();
408+
});
409+
410+
it('fires handleLinkCardPress when onboarding CTA is tapped', () => {
411+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
412+
expect(() => {
413+
fireEvent.press(getByTestId(MoneyOnboardingCardTestIds.CTA_BUTTON));
414+
}).not.toThrow();
415+
});
295416
});
296417

297418
describe('empty state (0 transactions)', () => {
@@ -335,5 +456,12 @@ describe('MoneyHomeView', () => {
335456
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
336457
expect(getByTestId(MoneyWhatYouGetTestIds.CONTAINER)).toBeOnTheScreen();
337458
});
459+
460+
it('fires handleAddPress when onboarding CTA is tapped', () => {
461+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
462+
expect(() => {
463+
fireEvent.press(getByTestId(MoneyOnboardingCardTestIds.CTA_BUTTON));
464+
}).not.toThrow();
465+
});
338466
});
339467
});

app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount';
33
import { ScrollView, Linking } from 'react-native';
44
import { useSafeAreaInsets } from 'react-native-safe-area-context';
55
import { useNavigation } from '@react-navigation/native';
6+
import { useSelector } from 'react-redux';
67
import { Box } from '@metamask/design-system-react-native';
78
import { useStyles } from '../../../../hooks/useStyles';
89
import MoneyHeader from '../../components/MoneyHeader';
@@ -30,6 +31,7 @@ import { MUSD_MAINNET_ASSET_FOR_DETAILS } from '../../../../Views/Homepage/Secti
3031
import { TokenDetailsSource } from '../../../TokenDetails/constants/constants';
3132
import AppConstants from '../../../../../core/AppConstants';
3233
import NavigationService from '../../../../../core/NavigationService';
34+
import { selectIsCardholder } from '../../../../../selectors/cardController';
3335

3436
const Divider = () => <Box twClassName="h-px bg-border-muted my-5" />;
3537

@@ -58,8 +60,11 @@ const MoneyHomeView = () => {
5860
const { tokens: conversionTokens } = useMusdConversionTokens();
5961
const { allTransactions, moneyAddress } = useMoneyAccountTransactions();
6062

63+
const isCardholder = useSelector(selectIsCardholder);
64+
6165
const homeState = getMoneyHomeState(allTransactions.length);
6266
const isMilestone = homeState === 'milestone' || homeState === 'filled';
67+
const isCardUnlinked = isMilestone && isCardholder;
6368

6469
const handleBackPress = useCallback(() => {
6570
navigation.goBack();
@@ -74,6 +79,7 @@ const MoneyHomeView = () => {
7479
const handleApyInfoPress = displayUnderConstructionAlert;
7580
const handleProjectedEarningsPress = displayUnderConstructionAlert;
7681
const handleGetNowPress = displayUnderConstructionAlert;
82+
const handleLinkCardPress = displayUnderConstructionAlert;
7783
const handleMusdRowPress = useCallback(() => {
7884
NavigationService.navigation.navigate('Asset', {
7985
...MUSD_MAINNET_ASSET_FOR_DETAILS,
@@ -102,6 +108,26 @@ const MoneyHomeView = () => {
102108
showMoneyActivityUnderConstructionAlert();
103109
}, []);
104110

111+
const handleOnboardingCtaPress = useCallback(() => {
112+
if (isCardUnlinked) {
113+
handleLinkCardPress();
114+
return;
115+
}
116+
117+
if (isMilestone) {
118+
handleCardPress();
119+
return;
120+
}
121+
122+
handleAddPress();
123+
}, [
124+
isCardUnlinked,
125+
isMilestone,
126+
handleLinkCardPress,
127+
handleCardPress,
128+
handleAddPress,
129+
]);
130+
105131
// TODO: Remove before launch
106132
// Useful for testing how zero and non-zero APYs are handled quickly.
107133
const DEV_APY = __DEV__ ? 4 : vaultApyQuery.data?.apy;
@@ -134,7 +160,8 @@ const MoneyHomeView = () => {
134160
/>
135161
<MoneyOnboardingCard
136162
currentStep={isMilestone ? 2 : 1}
137-
onCtaPress={isMilestone ? handleCardPress : handleAddPress}
163+
variant={isCardUnlinked ? 'link-card' : 'get-card'}
164+
onCtaPress={handleOnboardingCtaPress}
138165
/>
139166
<Divider />
140167
<MoneyEarnings onProjectedPress={handleProjectedEarningsPress} />
@@ -179,8 +206,11 @@ const MoneyHomeView = () => {
179206
</>
180207
)}
181208
<MoneyMetaMaskCard
209+
mode={isCardUnlinked ? 'link' : 'upsell'}
182210
onGetNowPress={handleGetNowPress}
183211
onHeaderPress={handleHeaderPress}
212+
onLinkPress={handleLinkCardPress}
213+
apy={DEV_APY}
184214
/>
185215
<Divider />
186216
{isMilestone && (

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,106 @@ describe('MoneyMetaMaskCard', () => {
6565

6666
expect(mockGetNow).toHaveBeenCalledWith('metal');
6767
});
68+
69+
describe('link mode', () => {
70+
it('renders link subtitle instead of upsell subtitle', () => {
71+
const { getByText, queryByText } = render(
72+
<MoneyMetaMaskCard mode="link" />,
73+
);
74+
75+
expect(
76+
getByText(strings('money.metamask_card.link_subtitle')),
77+
).toBeOnTheScreen();
78+
expect(
79+
queryByText(strings('money.metamask_card.subtitle')),
80+
).not.toBeOnTheScreen();
81+
});
82+
83+
it('renders card image in link mode', () => {
84+
const { getByTestId } = render(<MoneyMetaMaskCard mode="link" />);
85+
86+
expect(
87+
getByTestId(MoneyMetaMaskCardTestIds.LINK_CARD_IMAGE),
88+
).toBeOnTheScreen();
89+
});
90+
91+
it('renders cashback and APY bullets', () => {
92+
const { getByTestId } = render(<MoneyMetaMaskCard mode="link" apy={5} />);
93+
94+
expect(
95+
getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_CASHBACK),
96+
).toBeOnTheScreen();
97+
expect(
98+
getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY),
99+
).toBeOnTheScreen();
100+
});
101+
102+
it('renders "Link card" button', () => {
103+
const { getByTestId } = render(<MoneyMetaMaskCard mode="link" />);
104+
105+
expect(
106+
getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON),
107+
).toBeOnTheScreen();
108+
});
109+
110+
it('hides virtual and metal card rows in link mode', () => {
111+
const { queryByTestId } = render(<MoneyMetaMaskCard mode="link" />);
112+
113+
expect(
114+
queryByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW),
115+
).not.toBeOnTheScreen();
116+
expect(
117+
queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
118+
).not.toBeOnTheScreen();
119+
});
120+
121+
it('calls onLinkPress when "Link card" button is pressed', () => {
122+
const mockLink = jest.fn();
123+
const { getByTestId } = render(
124+
<MoneyMetaMaskCard mode="link" onLinkPress={mockLink} />,
125+
);
126+
127+
fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON));
128+
expect(mockLink).toHaveBeenCalledTimes(1);
129+
});
130+
131+
it('renders link-specific section title', () => {
132+
const { getByText } = render(<MoneyMetaMaskCard mode="link" />);
133+
134+
expect(
135+
getByText(strings('money.metamask_card.link_title')),
136+
).toBeOnTheScreen();
137+
});
138+
139+
it('calls onHeaderPress when section header is tapped in link mode', () => {
140+
const mockHeader = jest.fn();
141+
const { getByText } = render(
142+
<MoneyMetaMaskCard mode="link" onHeaderPress={mockHeader} />,
143+
);
144+
145+
fireEvent.press(getByText(strings('money.metamask_card.link_title')));
146+
expect(mockHeader).toHaveBeenCalled();
147+
});
148+
});
149+
150+
describe('upsell mode (default)', () => {
151+
it('renders virtual and metal card rows', () => {
152+
const { getByTestId } = render(<MoneyMetaMaskCard />);
153+
154+
expect(
155+
getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW),
156+
).toBeOnTheScreen();
157+
expect(
158+
getByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
159+
).toBeOnTheScreen();
160+
});
161+
162+
it('does not render link mode elements', () => {
163+
const { queryByTestId } = render(<MoneyMetaMaskCard />);
164+
165+
expect(
166+
queryByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON),
167+
).not.toBeOnTheScreen();
168+
});
169+
});
68170
});

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,10 @@ export const MoneyMetaMaskCardTestIds = {
22
CONTAINER: 'money-metamask-card-container',
33
VIRTUAL_CARD_ROW: 'money-metamask-card-virtual-card-row',
44
METAL_CARD_ROW: 'money-metamask-card-metal-card-row',
5+
LINK_CONTAINER: 'money-metamask-card-link-container',
6+
LINK_SUBTITLE: 'money-metamask-card-link-subtitle',
7+
LINK_CARD_IMAGE: 'money-metamask-card-link-card-image',
8+
LINK_BULLET_CASHBACK: 'money-metamask-card-link-bullet-cashback',
9+
LINK_BULLET_APY: 'money-metamask-card-link-bullet-apy',
10+
LINK_BUTTON: 'money-metamask-card-link-button',
511
} as const;

0 commit comments

Comments
 (0)