Skip to content

Commit eedc67b

Browse files
authored
feat(MUSD-739): hide Metal card outside US (#29735)
## **Description** Money Home's `MoneyMetaMaskCard` upsell currently shows two card rows (Virtual at 1% cashback, Metal at 3% cashback) to every user. The Metal card is only available to US users today, so this PR adds geolocation gating: the Metal card row is only rendered when the Ramps-detected geolocation positively resolves to `US`. Loading, unknown (`undefined`), and non-US country codes all fail closed and render only the Virtual card row. While here, both "Get now" buttons now route through the canonical `metamask://card-onboarding` deeplink (via `handleDeeplink`) instead of `navigation.navigate(Routes.CARD.ROOT)`, matching the upsell entry point used elsewhere (e.g. `EarnRewardsPreview`). `MoneyMetaMaskCard` accepts a new `showMetalCard` prop (default `false`) so the view layer keeps ownership of the geolocation read; the component stays a dumb presentational primitive. `MoneyHomeView` reads `getDetectedGeolocation` from `app/reducers/fiatOrders` and normalizes it the same way `useMusdConversionEligibility` does (`?.toUpperCase().split('-')[0] === 'US'`). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MUSD-739 ## **Manual testing steps** ```gherkin Feature: Metal card geolocation gating in Money Home Scenario: US user sees both Virtual and Metal card rows Given the user's Ramps geolocation has resolved to "US" When the user opens the Money home screen and scrolls to the MetaMask Card section in upsell mode Then the Virtual card row (1% cashback) is visible And the Metal card row (3% cashback) is visible Scenario: Non-US user sees only the Virtual card row Given the user's Ramps geolocation has resolved to "GB" (or any non-US code) When the user opens the Money home screen and scrolls to the MetaMask Card section in upsell mode Then the Virtual card row (1% cashback) is visible And the Metal card row is not rendered Scenario: Unknown / loading geolocation hides the Metal card row Given the Ramps geolocation has not yet resolved (undefined) When the user opens the Money home screen and scrolls to the MetaMask Card section in upsell mode Then only the Virtual card row is visible Scenario: Get now button opens the card-onboarding deeplink Given the MetaMask Card upsell section is visible When the user taps the "Get now" button on either the Virtual or Metal card row Then the metamask://card-onboarding deeplink is dispatched ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] 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** - [ ] 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] > **Low Risk** > Low risk UI change that conditionally hides the Metal card upsell row based on the existing ramps geolocation selector; main risk is incorrect geolocation normalization causing the Metal row to appear/disappear unexpectedly. > > **Overview** > Money Home now **hides the Metal card upsell row outside the US** by reading `getDetectedGeolocation` and passing a new `showMetalCard` flag into `MoneyMetaMaskCard` (US and US sub-regions like `US-CA` only; `undefined`/non-US fail closed). > > `MoneyMetaMaskCard` is updated to accept `showMetalCard` (default `false`) and only render the Metal row when enabled, with tests expanded to cover the new gating and MoneyHomeView integration. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 616aab6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent aac019d commit eedc67b

4 files changed

Lines changed: 149 additions & 15 deletions

File tree

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { strings } from '../../../../../../locales/i18n';
2323
import MOCK_MONEY_TRANSACTIONS from '../../constants/mockActivityData';
2424
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
2525
import { selectIsCardholder } from '../../../../../selectors/cardController';
26+
import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders';
2627
import { moneyFormatFiat } from '../../utils/moneyFormatFiat';
2728

2829
const mockGoBack = jest.fn();
@@ -85,7 +86,13 @@ jest.mock('../../../../../selectors/cardController', () => ({
8586
selectIsCardholder: jest.fn(),
8687
}));
8788

89+
jest.mock('../../../../../reducers/fiatOrders', () => ({
90+
...jest.requireActual('../../../../../reducers/fiatOrders'),
91+
getDetectedGeolocation: jest.fn(),
92+
}));
93+
8894
const mockSelectIsCardholder = jest.mocked(selectIsCardholder);
95+
const mockGetDetectedGeolocation = jest.mocked(getDetectedGeolocation);
8996

9097
const mockUseMoneyAccountTransactions = jest.mocked(
9198
useMoneyAccountTransactions,
@@ -134,6 +141,7 @@ describe('MoneyHomeView', () => {
134141
global.alert = jest.fn();
135142

136143
mockSelectIsCardholder.mockReturnValue(false);
144+
mockGetDetectedGeolocation.mockReturnValue('US');
137145

138146
mockUseMoneyAccountBalance.mockReturnValue({
139147
totalFiatFormatted: '$3.00',
@@ -592,4 +600,82 @@ describe('MoneyHomeView', () => {
592600
});
593601
});
594602
});
603+
604+
describe('Metal card geolocation gating', () => {
605+
it('renders the Metal card row when geolocation is US', () => {
606+
mockGetDetectedGeolocation.mockReturnValue('US');
607+
608+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
609+
610+
expect(
611+
getByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
612+
).toBeOnTheScreen();
613+
expect(
614+
getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW),
615+
).toBeOnTheScreen();
616+
});
617+
618+
it('renders the Metal card row when geolocation is a US sub-region (e.g. US-CA)', () => {
619+
mockGetDetectedGeolocation.mockReturnValue('us-ca');
620+
621+
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
622+
623+
expect(
624+
getByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
625+
).toBeOnTheScreen();
626+
});
627+
628+
it('hides the Metal card row when geolocation is GB', () => {
629+
mockGetDetectedGeolocation.mockReturnValue('GB');
630+
631+
const { queryByTestId, getByTestId } = renderWithProvider(
632+
<MoneyHomeView />,
633+
);
634+
635+
expect(
636+
queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
637+
).not.toBeOnTheScreen();
638+
expect(
639+
getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW),
640+
).toBeOnTheScreen();
641+
});
642+
643+
it('hides the Metal card row when geolocation is undefined (loading/unknown - fail closed)', () => {
644+
mockGetDetectedGeolocation.mockReturnValue(undefined);
645+
646+
const { queryByTestId, getByTestId } = renderWithProvider(
647+
<MoneyHomeView />,
648+
);
649+
650+
expect(
651+
queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
652+
).not.toBeOnTheScreen();
653+
expect(
654+
getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW),
655+
).toBeOnTheScreen();
656+
});
657+
});
658+
659+
describe('Get now navigation', () => {
660+
it('navigates to the card sign-up flow when the virtual card Get now button is pressed', () => {
661+
mockGetDetectedGeolocation.mockReturnValue('GB');
662+
663+
const { getByText } = renderWithProvider(<MoneyHomeView />);
664+
665+
fireEvent.press(getByText(strings('money.metamask_card.get_now')));
666+
667+
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT);
668+
});
669+
670+
it('navigates to the card sign-up flow when the metal card Get now button is pressed', () => {
671+
mockGetDetectedGeolocation.mockReturnValue('US');
672+
673+
const { getAllByText } = renderWithProvider(<MoneyHomeView />);
674+
const buttons = getAllByText(strings('money.metamask_card.get_now'));
675+
676+
fireEvent.press(buttons[1]);
677+
678+
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT);
679+
});
680+
});
595681
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { TokenDetailsSource } from '../../../TokenDetails/constants/constants';
3636
import AppConstants from '../../../../../core/AppConstants';
3737
import NavigationService from '../../../../../core/NavigationService';
3838
import { selectIsCardholder } from '../../../../../selectors/cardController';
39+
import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders';
3940
import Logger from '../../../../../util/Logger';
4041
import { AssetType } from '../../../../Views/confirmations/types/token';
4142
import { Hex } from '@metamask/utils';
@@ -73,6 +74,8 @@ const MoneyHomeView = () => {
7374
const { allTransactions, moneyAddress } = useMoneyAccountTransactions();
7475

7576
const isCardholder = useSelector(selectIsCardholder);
77+
const geolocation = useSelector(getDetectedGeolocation);
78+
const isUS = geolocation?.toUpperCase().split('-')[0] === 'US';
7679

7780
const homeState = getMoneyHomeState(allTransactions.length);
7881
const isMilestone = homeState === 'milestone' || homeState === 'filled';
@@ -296,6 +299,7 @@ const MoneyHomeView = () => {
296299
onHeaderPress={handleHeaderPress}
297300
onLinkPress={handleLinkCardPress}
298301
apy={apyPercent}
302+
showMetalCard={isUS}
299303
/>
300304
<Divider />
301305
{isMilestone && (

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

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ describe('MoneyMetaMaskCard', () => {
3232
).toBeOnTheScreen();
3333
});
3434

35-
it('renders metal card row', () => {
35+
it('renders metal card row when showMetalCard is true', () => {
3636
const { getByText, getByTestId } = render(
37-
<MoneyMetaMaskCard onGetNowPress={jest.fn()} />,
37+
<MoneyMetaMaskCard onGetNowPress={jest.fn()} showMetalCard />,
3838
);
3939

4040
expect(
@@ -48,14 +48,36 @@ describe('MoneyMetaMaskCard', () => {
4848
).toBeOnTheScreen();
4949
});
5050

51+
it('hides metal card row by default (showMetalCard not provided)', () => {
52+
const { queryByTestId, queryByText } = render(
53+
<MoneyMetaMaskCard onGetNowPress={jest.fn()} />,
54+
);
55+
56+
expect(
57+
queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
58+
).not.toBeOnTheScreen();
59+
expect(
60+
queryByText(strings('money.metamask_card.metal_card')),
61+
).not.toBeOnTheScreen();
62+
});
63+
64+
it('hides metal card row when showMetalCard is false', () => {
65+
const { queryByTestId } = render(
66+
<MoneyMetaMaskCard onGetNowPress={jest.fn()} showMetalCard={false} />,
67+
);
68+
69+
expect(
70+
queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
71+
).not.toBeOnTheScreen();
72+
});
73+
5174
it('calls onGetNowPress when virtual card Get now is pressed', () => {
5275
const mockGetNow = jest.fn();
53-
const { getAllByText } = render(
76+
const { getByText } = render(
5477
<MoneyMetaMaskCard onGetNowPress={mockGetNow} />,
5578
);
56-
const getNowButtons = getAllByText(strings('money.metamask_card.get_now'));
5779

58-
fireEvent.press(getNowButtons[0]);
80+
fireEvent.press(getByText(strings('money.metamask_card.get_now')));
5981

6082
expect(mockGetNow).toHaveBeenCalledTimes(1);
6183
expect(mockGetNow.mock.calls[0]).toEqual([]);
@@ -64,7 +86,7 @@ describe('MoneyMetaMaskCard', () => {
6486
it('calls onGetNowPress when metal card Get now is pressed', () => {
6587
const mockGetNow = jest.fn();
6688
const { getAllByText } = render(
67-
<MoneyMetaMaskCard onGetNowPress={mockGetNow} />,
89+
<MoneyMetaMaskCard onGetNowPress={mockGetNow} showMetalCard />,
6890
);
6991
const getNowButtons = getAllByText(strings('money.metamask_card.get_now'));
7092

@@ -174,9 +196,9 @@ describe('MoneyMetaMaskCard', () => {
174196
});
175197

176198
describe('upsell mode (default)', () => {
177-
it('renders virtual and metal card rows', () => {
199+
it('renders virtual and metal card rows when showMetalCard is true', () => {
178200
const { getByTestId } = render(
179-
<MoneyMetaMaskCard onGetNowPress={jest.fn()} />,
201+
<MoneyMetaMaskCard onGetNowPress={jest.fn()} showMetalCard />,
180202
);
181203

182204
expect(
@@ -187,6 +209,19 @@ describe('MoneyMetaMaskCard', () => {
187209
).toBeOnTheScreen();
188210
});
189211

212+
it('renders only the virtual card row when showMetalCard is false', () => {
213+
const { getByTestId, queryByTestId } = render(
214+
<MoneyMetaMaskCard onGetNowPress={jest.fn()} showMetalCard={false} />,
215+
);
216+
217+
expect(
218+
getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW),
219+
).toBeOnTheScreen();
220+
expect(
221+
queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW),
222+
).not.toBeOnTheScreen();
223+
});
224+
190225
it('does not render link mode elements', () => {
191226
const { queryByTestId } = render(
192227
<MoneyMetaMaskCard onGetNowPress={jest.fn()} />,

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ interface MoneyMetaMaskCardProps {
3434
onLinkPress?: () => void;
3535
/** Current APY value displayed in the link mode bullet. */
3636
apy?: number;
37+
/**
38+
* Whether to render the Metal card row in upsell mode. Defaults to `false`
39+
* because the Metal card is currently only available to US users; the parent
40+
* is expected to pass the geolocation-derived flag.
41+
*/
42+
showMetalCard?: boolean;
3743
}
3844

3945
const CardRow = ({
@@ -166,6 +172,7 @@ const MoneyMetaMaskCard = ({
166172
onHeaderPress,
167173
onLinkPress,
168174
apy,
175+
showMetalCard = false,
169176
}: MoneyMetaMaskCardProps) => {
170177
const handleLinkPress = useCallback(() => onLinkPress?.(), [onLinkPress]);
171178

@@ -200,13 +207,15 @@ const MoneyMetaMaskCard = ({
200207
onPress={onGetNowPress}
201208
testID={MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW}
202209
/>
203-
<CardRow
204-
imageSource={mmCardMetal}
205-
cardName={strings('money.metamask_card.metal_card')}
206-
cashbackPercentage="3"
207-
onPress={onGetNowPress}
208-
testID={MoneyMetaMaskCardTestIds.METAL_CARD_ROW}
209-
/>
210+
{showMetalCard && (
211+
<CardRow
212+
imageSource={mmCardMetal}
213+
cardName={strings('money.metamask_card.metal_card')}
214+
cashbackPercentage="3"
215+
onPress={onGetNowPress}
216+
testID={MoneyMetaMaskCardTestIds.METAL_CARD_ROW}
217+
/>
218+
)}
210219
</>
211220
)}
212221
</Box>

0 commit comments

Comments
 (0)