Skip to content

Commit ffc2566

Browse files
authored
feat(card): Onboarding deep link (MetaMask#24042)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- 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? --> ### Reason for the change We need to add a new deeplink (`card-onboarding`) to allow marketing campaigns and external sources to direct users to the MetaMask Card onboarding flow. This deeplink intelligently routes users based on their card status. ### What's included **1. New `card-onboarding` deeplink handler** - Adds new deeplink action `CARD_ONBOARDING` supporting URLs like: - `https://link.metamask.io/card-onboarding` - `https://metamask.app.link/card-onboarding` - Smart routing based on user state: - **Authenticated or has card-linked account**: Switches to first cardholder account → navigates to CardHome → shows toast notification - **Not authenticated and no card-linked account**: Navigates to CardWelcome (onboarding screen) - Respects feature flags and geo-location restrictions before enabling - Dispatches `setAlwaysShowCardButton(true)` to ensure card button visibility - Full analytics tracking with `CARD_ONBOARDING_DEEPLINK` event **2. CardHome toast notification** - Adds route params support (`showDeeplinkToast`) to display a toast when user arrives via deeplink - Shows "You already have a MetaMask Card linked to this account" message **3. CardButton simplification** - Removes the "New" badge wrapper from the CardButton component - Simplifies the component by removing badge-related logic and state management ## **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 new card-onboarding deeplink to navigate users to the MetaMask Card feature ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card Onboarding Deeplink Scenario: Authenticated user with card-linked account opens deeplink Given user has the MetaMask app installed And user is authenticated with the Card feature And user has a card-linked account When user opens the card-onboarding deeplink Then app switches to the first cardholder account And user is navigated to the Card Home screen And a toast notification is displayed saying "You already have a MetaMask Card linked to this account" Scenario: Authenticated user without card-linked account opens deeplink Given user has the MetaMask app installed And user is authenticated with the Card feature And user has no card-linked account When user opens the card-onboarding deeplink Then user is navigated to the Card Home screen And a toast notification is displayed Scenario: Unauthenticated user without card opens deeplink Given user has the MetaMask app installed And user is not authenticated with the Card feature And user has no card-linked account When user opens the card-onboarding deeplink Then user is navigated to the Card Welcome screen And no toast notification is displayed Scenario: User in unsupported region opens deeplink Given user has the MetaMask app installed And user is in an unsupported region And card experimental switch is disabled When user opens the card-onboarding deeplink Then nothing happens (deeplink is ignored) Scenario: Card button is always shown after deeplink Given user has opened the card-onboarding deeplink When user navigates to the wallet home screen Then the card button is visible in the header ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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 - [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. ## **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] > Adds a `card-onboarding` deeplink that conditionally enables Card, navigates to the right Card screen (and switches to a card-linked account), shows a toast on `CardHome`, and tracks analytics; updates gating logic, caching behavior, routes, and tests. > > - **Deeplink & Navigation**: > - Add `ACTIONS.CARD_ONBOARDING` and whitelist it in universal link handling. > - Implement `handleCardOnboarding` with feature-flag/geo checks, optional account switch to first cardholder, navigation to `CardHome`/`CardWelcome`, and analytics tracking. > - Extend `Routes.CARD` (e.g., `CARD_MAIN_ROUTES`). > - **CardHome**: > - Add route param support via `useRoute` and show toast (`showDeeplinkToast`) when navigated from deeplink. > - Minor test refactors for auth-error cases and rerender logic. > - **Gating & Hooks**: > - Enhance `useIsBaanxLoginEnabled` to consider `cardGeoLocation` and `cardSupportedCountries` (with pure helper). > - Update `useGetDelegationSettings` caching to include `fetchOnMount` (auth-aware). > - **Analytics & Metrics**: > - Add `MetaMetricsEvents.CARD_ONBOARDING_DEEPLINK` and `CardDeeplinkActions`. > - **Constants & i18n**: > - Update deeplink constants/prefixes; > - Add string `card.card_button_already_enabled_toast`. > - **Tests**: > - Comprehensive tests for deeplink handler, gating logic, and updated hooks/components. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ef3a4bf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5b18690 commit ffc2566

14 files changed

Lines changed: 1090 additions & 86 deletions

File tree

app/components/UI/Card/Views/CardHome/CardHome.test.tsx

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,7 @@ import { useSelector } from 'react-redux';
4343
import React from 'react';
4444
import CardHome from './CardHome';
4545
import { cardDefaultNavigationOptions } from '../../routes';
46-
import renderWithProvider, {
47-
renderScreen,
48-
} from '../../../../../util/test/renderWithProvider';
46+
import { renderScreen } from '../../../../../util/test/renderWithProvider';
4947
import { withCardSDK } from '../../sdk';
5048
import { backgroundState } from '../../../../../util/test/initial-root-state';
5149
import Routes from '../../../../../constants/navigation/Routes';
@@ -2497,7 +2495,7 @@ describe('CardHome Component', () => {
24972495
});
24982496
});
24992497

2500-
it('does nothing when no error exists', async () => {
2498+
it('does nothing when no error exists', () => {
25012499
// Given: authenticated user without error
25022500
setupMockSelectors({ isAuthenticated: true });
25032501
mockIsAuthenticationError.mockReturnValue(false);
@@ -2510,7 +2508,6 @@ describe('CardHome Component', () => {
25102508
render();
25112509

25122510
// Then: should not trigger authentication error handling
2513-
await new Promise((r) => setTimeout(r, 100));
25142511
expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled();
25152512
expect(mockResetAuthenticatedData).not.toHaveBeenCalled();
25162513
expect(mockClearAllCache).not.toHaveBeenCalled();
@@ -2519,7 +2516,7 @@ describe('CardHome Component', () => {
25192516
);
25202517
});
25212518

2522-
it('does nothing when user is not authenticated', async () => {
2519+
it('does nothing when user is not authenticated', () => {
25232520
// Given: non-authenticated user with error
25242521
setupMockSelectors({ isAuthenticated: false });
25252522
mockIsAuthenticationError.mockReturnValue(false);
@@ -2532,13 +2529,12 @@ describe('CardHome Component', () => {
25322529
render();
25332530

25342531
// Then: should not trigger authentication error handling
2535-
await new Promise((r) => setTimeout(r, 100));
25362532
expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled();
25372533
expect(mockResetAuthenticatedData).not.toHaveBeenCalled();
25382534
expect(mockClearAllCache).not.toHaveBeenCalled();
25392535
});
25402536

2541-
it('does nothing when error is not an authentication error', async () => {
2537+
it('does nothing when error is not an authentication error', () => {
25422538
// Given: authenticated user with non-authentication error
25432539
setupMockSelectors({ isAuthenticated: true });
25442540
mockIsAuthenticationError.mockReturnValue(false);
@@ -2551,7 +2547,6 @@ describe('CardHome Component', () => {
25512547
render();
25522548

25532549
// Then: should not trigger authentication error handling
2554-
await new Promise((r) => setTimeout(r, 100));
25552550
expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled();
25562551
expect(mockResetAuthenticatedData).not.toHaveBeenCalled();
25572552
expect(mockClearAllCache).not.toHaveBeenCalled();
@@ -2668,8 +2663,8 @@ describe('CardHome Component', () => {
26682663
// Given: authenticated user with persistent authentication error
26692664
setupMockSelectors({ isAuthenticated: true });
26702665
mockIsAuthenticationError.mockReturnValue(true);
2671-
const WrappedCardHome = withCardSDK(CardHome);
26722666

2667+
// Setup mock to return same error for multiple renders
26732668
setupLoadCardDataMock({
26742669
error: 'First auth error',
26752670
isAuthenticated: true,
@@ -2684,27 +2679,13 @@ describe('CardHome Component', () => {
26842679
priorityToken: mockPriorityToken,
26852680
});
26862681

2687-
// When: component renders twice with the same authentication error
2688-
const { rerender } = renderWithProvider(<WrappedCardHome />, {
2689-
state: {
2690-
engine: {
2691-
backgroundState,
2692-
},
2693-
},
2694-
});
2682+
// When: component renders with authentication error
2683+
render();
26952684

26962685
// Then: cleanup runs once on initial render
26972686
await waitFor(() => {
26982687
expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1);
26992688
});
2700-
2701-
// When: component re-renders with same error
2702-
rerender(<WrappedCardHome />);
2703-
2704-
// Then: cleanup does not run again for unchanged error
2705-
await waitFor(() => {
2706-
expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1);
2707-
});
27082689
});
27092690

27102691
it('does not dispatch Redux actions if token removal throws and component unmounts', async () => {

app/components/UI/Card/Views/CardHome/CardHome.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ import Icon, {
2323
import Text, {
2424
TextVariant,
2525
} from '../../../../../component-library/components/Texts/Text';
26-
import { StackActions, useNavigation } from '@react-navigation/native';
26+
import {
27+
StackActions,
28+
useNavigation,
29+
useRoute,
30+
RouteProp,
31+
} from '@react-navigation/native';
2732
import { useDispatch, useSelector } from 'react-redux';
2833
import SensitiveText, {
2934
SensitiveTextLength,
@@ -87,6 +92,13 @@ import SpendingLimitProgressBar from '../../components/SpendingLimitProgressBar/
8792
import { createAddFundsModalNavigationDetails } from '../../components/AddFundsBottomSheet/AddFundsBottomSheet';
8893
import { createAssetSelectionModalNavigationDetails } from '../../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet';
8994

95+
/**
96+
* Route params for CardHome screen
97+
*/
98+
interface CardHomeRouteParams {
99+
showDeeplinkToast?: boolean;
100+
}
101+
90102
/**
91103
* CardHome Component
92104
*
@@ -111,11 +123,14 @@ const CardHome = () => {
111123
const isComponentUnmountedRef = useRef(false);
112124
const hasShownKYCAlertRef = useRef(false);
113125
const hasShownKYCErrorAlertRef = useRef(false);
126+
const hasShownDeeplinkToast = useRef(false);
114127
const [
115128
isCloseSpendingLimitWarningShown,
116129
setIsCloseSpendingLimitWarningShown,
117130
] = useState(true);
118131

132+
const route =
133+
useRoute<RouteProp<{ params: CardHomeRouteParams }, 'params'>>();
119134
const { trackEvent, createEventBuilder } = useMetrics();
120135
const navigation = useNavigation();
121136
const dispatch = useDispatch();
@@ -260,6 +275,25 @@ const CardHome = () => {
260275
isSDKLoading,
261276
]);
262277

278+
// Show toast notification when navigating from deeplink
279+
useEffect(() => {
280+
if (
281+
route.params?.showDeeplinkToast &&
282+
!hasShownDeeplinkToast.current &&
283+
toastRef?.current
284+
) {
285+
hasShownDeeplinkToast.current = true;
286+
toastRef.current.showToast({
287+
variant: ToastVariants.Icon,
288+
labelOptions: [
289+
{ label: strings('card.card_button_already_enabled_toast') },
290+
],
291+
hasNoTimeout: false,
292+
iconName: IconName.Info,
293+
});
294+
}
295+
}, [route.params?.showDeeplinkToast, toastRef]);
296+
263297
const addFundsAction = useCallback(() => {
264298
trackEvent(
265299
createEventBuilder(MetaMetricsEvents.CARD_ADD_FUNDS_CLICKED).build(),

0 commit comments

Comments
 (0)