Skip to content

Commit 96c3303

Browse files
chore(runway): cherry-pick feat(card): add card FREEZE, BLOCKED warnings and card provisioning flow (#21641)
- feat(card): add card FREEZE, BLOCKED warnings and card provisioning flow (#21489) <!-- 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? --> This PR introduces specific warning messages for cards with BLOCKED and FREEZED statuses, improving user feedback and clarity during card interactions. It also adds support for card provisioning, allowing users to initiate the setup process for a new card directly within the app. ## **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 distinct warnings for BLOCKED and FREEZED card statuses CHANGELOG entry: Enabled card provisioning flow for eligible users ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **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] > Implements card provisioning (UI + hooks + SDK), adds status/warning handling (NoCard/Frozen/Blocked/NeedDelegation), updates CardImage with status/opacity and address, and expands tests/i18n. > > - **Card Home (UI)**: > - Show `CardWarningBox` from `cardDetailsWarning`; hide sections on `NeedDelegation`. > - Add enable actions: `ENABLE_CARD_BUTTON` (provision + poll, then fetch token/change asset) and `ENABLE_ASSETS_BUTTON` (change asset). > - Button states reflect provisioning/polling/loading. > - **Hooks**: > - `useCardDetails`: track `warning`, `isLoadingPollCardStatusUntilProvisioned`; set warnings from `CardStatus`; add `pollCardStatusUntilProvisioned` to await ACTIVE status; handle `NO_CARD` without error. > - New `useCardProvision`: orchestrates `sdk.provisionCard()` with loading and error logging. > - **SDK**: > - Add `provisionCard()` (`POST /v1/card/order` with `CardType.VIRTUAL`), auth headers, and error handling. > - **CardImage**: > - Accept `status` and `address`; lower opacity for `FROZEN/BLOCKED`; truncate and render address. > - **Warnings**: > - `CardWarningBox`: support `Frozen/Blocked/NoCard`; hide box for `NoCard/NeedDelegation`; conditional buttons; minor style tweaks. > - **Types/Selectors/Strings**: > - Add `CardStatus`, extend `CardWarning`, new test IDs; update `en.json` strings for buttons and warnings. > - **Tests**: > - Extensive updates and new tests for provisioning flow, warnings, polling, CardImage status/address, and SDK endpoint; remove obsolete snapshots. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ffaf970. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [c05d9d2](c05d9d2) Co-authored-by: Bruno Nascimento <[email protected]>
1 parent 0a30ffa commit 96c3303

File tree

18 files changed

+1581
-3887
lines changed

18 files changed

+1581
-3887
lines changed

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

Lines changed: 316 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { useGetPriorityCardToken } from '../../hooks/useGetPriorityCardToken';
3333
import { strings } from '../../../../../../locales/i18n';
3434
import { useAssetBalance } from '../../hooks/useAssetBalance';
3535
import { useNavigateToCardPage } from '../../hooks/useNavigateToCardPage';
36-
import { AllowanceState, CardType, CardWarning } from '../../types';
36+
import { AllowanceState, CardStatus, CardType, CardWarning } from '../../types';
3737
import CardAssetItem from '../../components/CardAssetItem';
3838
import ManageCardListItem from '../../components/ManageCardListItem';
3939
import CardImage from '../../components/CardImage';
@@ -54,8 +54,9 @@ import { useCardSDK } from '../../sdk';
5454
import Routes from '../../../../../constants/navigation/Routes';
5555
import useIsBaanxLoginEnabled from '../../hooks/isBaanxLoginEnabled';
5656
import useCardDetails from '../../hooks/useCardDetails';
57-
import CardWarningBox from '../../components/CardWarningBox/CardWarningBox';
5857
import { selectIsAuthenticatedCard } from '../../../../../core/redux/slices/card';
58+
import { useCardProvision } from '../../hooks/useCardProvision';
59+
import CardWarningBox from '../../components/CardWarningBox/CardWarningBox';
5960
import { useIsSwapEnabledForPriorityToken } from '../../hooks/useIsSwapEnabledForPriorityToken';
6061

6162
/**
@@ -98,10 +99,15 @@ const CardHome = () => {
9899
useAssetBalance(priorityToken);
99100
const {
100101
cardDetails,
102+
pollCardStatusUntilProvisioned,
101103
fetchCardDetails,
102104
isLoading: isLoadingCardDetails,
103105
error: cardDetailsError,
106+
warning: cardDetailsWarning,
107+
isLoadingPollCardStatusUntilProvisioned,
104108
} = useCardDetails();
109+
const { provisionCard, isLoading: isLoadingProvisionCard } =
110+
useCardProvision();
105111
const { navigateToCardPage } = useNavigateToCardPage(navigation);
106112
const { openSwaps } = useOpenSwaps({
107113
priorityToken,
@@ -259,6 +265,21 @@ const CardHome = () => {
259265
[isLoadingPriorityToken, isLoadingCardDetails],
260266
);
261267

268+
const enableCardAction = useCallback(async () => {
269+
await provisionCard();
270+
const isProvisioned = await pollCardStatusUntilProvisioned();
271+
272+
if (isProvisioned) {
273+
fetchPriorityToken();
274+
changeAssetAction();
275+
}
276+
}, [
277+
provisionCard,
278+
pollCardStatusUntilProvisioned,
279+
fetchPriorityToken,
280+
changeAssetAction,
281+
]);
282+
262283
const ButtonsSection = useMemo(() => {
263284
if (isLoading) {
264285
return (
@@ -272,7 +293,45 @@ const CardHome = () => {
272293
}
273294

274295
if (isBaanxLoginEnabled) {
275-
if (priorityTokenWarning === CardWarning.NeedDelegation) return null;
296+
if (cardDetailsWarning === CardWarning.NoCard) {
297+
return (
298+
<Button
299+
variant={ButtonVariants.Primary}
300+
style={styles.defaultMarginTop}
301+
label={strings('card.card_home.enable_card_button_label')}
302+
size={ButtonSize.Lg}
303+
onPress={enableCardAction}
304+
width={ButtonWidthTypes.Full}
305+
disabled={
306+
isLoading ||
307+
isLoadingPollCardStatusUntilProvisioned ||
308+
isLoadingProvisionCard
309+
}
310+
loading={
311+
isLoading ||
312+
isLoadingPollCardStatusUntilProvisioned ||
313+
isLoadingProvisionCard
314+
}
315+
testID={CardHomeSelectors.ENABLE_CARD_BUTTON}
316+
/>
317+
);
318+
}
319+
320+
if (priorityTokenWarning === CardWarning.NeedDelegation) {
321+
return (
322+
<Button
323+
variant={ButtonVariants.Primary}
324+
style={styles.defaultMarginTop}
325+
label={strings('card.card_home.enable_assets_button_label')}
326+
size={ButtonSize.Lg}
327+
onPress={changeAssetAction}
328+
width={ButtonWidthTypes.Full}
329+
disabled={isLoading}
330+
loading={isLoading}
331+
testID={CardHomeSelectors.ENABLE_ASSETS_BUTTON}
332+
/>
333+
);
334+
}
276335

277336
return (
278337
<View style={styles.buttonsContainer}>
@@ -380,12 +439,7 @@ const CardHome = () => {
380439
alwaysBounceVertical={false}
381440
contentContainerStyle={styles.contentContainer}
382441
>
383-
{priorityTokenWarning && (
384-
<CardWarningBox
385-
warning={priorityTokenWarning}
386-
onConfirm={addFundsAction}
387-
/>
388-
)}
442+
{cardDetailsWarning && <CardWarningBox warning={cardDetailsWarning} />}
389443
<View style={styles.cardBalanceContainer}>
390444
<View
391445
style={[
@@ -467,6 +521,7 @@ const CardHome = () => {
467521
) : (
468522
<CardImage
469523
type={cardDetails?.type ?? CardType.VIRTUAL}
524+
status={cardDetails?.status ?? CardStatus.ACTIVE}
470525
address={priorityToken?.walletAddress}
471526
/>
472527
)}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ exports[`CardHome Component renders correctly and matches snapshot 1`] = `
520520
}
521521
}
522522
>
523-
card.card_home.change_asset
523+
Change asset
524524
</Text>
525525
</TouchableOpacity>
526526
</View>
@@ -578,7 +578,7 @@ exports[`CardHome Component renders correctly and matches snapshot 1`] = `
578578
}
579579
}
580580
>
581-
card.card_home.manage_card_options.manage_spending_limit
581+
Manage spending limit
582582
</Text>
583583
<Text
584584
accessibilityRole="text"
@@ -592,7 +592,7 @@ exports[`CardHome Component renders correctly and matches snapshot 1`] = `
592592
}
593593
}
594594
>
595-
card.card_home.manage_card_options.manage_spending_limit_description_full
595+
Full spending access
596596
</Text>
597597
</View>
598598
<View
@@ -1262,7 +1262,7 @@ exports[`CardHome Component renders correctly with privacy mode enabled 1`] = `
12621262
}
12631263
}
12641264
>
1265-
card.card_home.change_asset
1265+
Change asset
12661266
</Text>
12671267
</TouchableOpacity>
12681268
</View>
@@ -1320,7 +1320,7 @@ exports[`CardHome Component renders correctly with privacy mode enabled 1`] = `
13201320
}
13211321
}
13221322
>
1323-
card.card_home.manage_card_options.manage_spending_limit
1323+
Manage spending limit
13241324
</Text>
13251325
<Text
13261326
accessibilityRole="text"
@@ -1334,7 +1334,7 @@ exports[`CardHome Component renders correctly with privacy mode enabled 1`] = `
13341334
}
13351335
}
13361336
>
1337-
card.card_home.manage_card_options.manage_spending_limit_description_full
1337+
Full spending access
13381338
</Text>
13391339
</View>
13401340
<View
Lines changed: 122 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import React from 'react';
22
import CardImage from './CardImage';
3-
import { CardType } from '../../types';
3+
import { CardType, CardStatus } from '../../types';
44
import { renderScreen } from '../../../../../util/test/renderWithProvider';
55
import { backgroundState } from '../../../../../util/test/initial-root-state';
66

7+
jest.mock('../../util/truncateAddress', () => ({
8+
truncateAddress: jest.fn(
9+
(address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`,
10+
),
11+
}));
12+
713
function renderWithProvider(component: React.ComponentType) {
814
return renderScreen(
915
component,
@@ -21,57 +27,144 @@ function renderWithProvider(component: React.ComponentType) {
2127
}
2228

2329
describe('CardImage Component', () => {
24-
it('renders virtual card image when type is VIRTUAL', () => {
25-
const { toJSON, getByTestId } = renderWithProvider(() => (
26-
<CardImage type={CardType.VIRTUAL} testID="virtual-card-image" />
30+
it('renders virtual card image for VIRTUAL type', () => {
31+
const { getByTestId } = renderWithProvider(() => (
32+
<CardImage
33+
type={CardType.VIRTUAL}
34+
status={CardStatus.ACTIVE}
35+
testID="virtual-card-image"
36+
/>
2737
));
2838

29-
expect(getByTestId('virtual-card-image')).toBeDefined();
30-
expect(toJSON()).toMatchSnapshot();
39+
expect(getByTestId('virtual-card-image')).toBeOnTheScreen();
3140
});
3241

33-
it('renders metal card image when type is PHYSICAL', () => {
34-
const { toJSON, getByTestId } = renderWithProvider(() => (
35-
<CardImage type={CardType.PHYSICAL} testID="physical-card-image" />
42+
it('renders metal card image for PHYSICAL type', () => {
43+
const { getByTestId } = renderWithProvider(() => (
44+
<CardImage
45+
type={CardType.PHYSICAL}
46+
status={CardStatus.ACTIVE}
47+
testID="physical-card-image"
48+
/>
3649
));
3750

38-
expect(getByTestId('physical-card-image')).toBeDefined();
39-
expect(toJSON()).toMatchSnapshot();
51+
expect(getByTestId('physical-card-image')).toBeOnTheScreen();
4052
});
4153

42-
it('renders metal card image when type is METAL', () => {
43-
const { toJSON, getByTestId } = renderWithProvider(() => (
44-
<CardImage type={CardType.METAL} testID="metal-card-image" />
54+
it('renders metal card image for METAL type', () => {
55+
const { getByTestId } = renderWithProvider(() => (
56+
<CardImage
57+
type={CardType.METAL}
58+
status={CardStatus.ACTIVE}
59+
testID="metal-card-image"
60+
/>
4561
));
4662

47-
expect(getByTestId('metal-card-image')).toBeDefined();
48-
expect(toJSON()).toMatchSnapshot();
63+
expect(getByTestId('metal-card-image')).toBeOnTheScreen();
4964
});
5065

51-
it('renders with custom SVG properties', () => {
52-
const { toJSON } = renderWithProvider(() => (
66+
it('applies lower opacity when status is FROZEN', () => {
67+
const { getByTestId } = renderWithProvider(() => (
5368
<CardImage
5469
type={CardType.VIRTUAL}
55-
width={200}
56-
height={100}
57-
fill="red"
58-
stroke="blue"
59-
opacity={0.5}
60-
testID="custom-card-image"
70+
status={CardStatus.FROZEN}
71+
testID="frozen-card"
72+
/>
73+
));
74+
75+
expect(getByTestId('frozen-card')).toBeOnTheScreen();
76+
});
77+
78+
it('applies lower opacity when status is BLOCKED', () => {
79+
const { getByTestId } = renderWithProvider(() => (
80+
<CardImage
81+
type={CardType.VIRTUAL}
82+
status={CardStatus.BLOCKED}
83+
testID="blocked-card"
84+
/>
85+
));
86+
87+
expect(getByTestId('blocked-card')).toBeOnTheScreen();
88+
});
89+
90+
it('renders with full opacity when status is ACTIVE', () => {
91+
const { getByTestId } = renderWithProvider(() => (
92+
<CardImage
93+
type={CardType.VIRTUAL}
94+
status={CardStatus.ACTIVE}
95+
testID="active-card"
96+
/>
97+
));
98+
99+
expect(getByTestId('active-card')).toBeOnTheScreen();
100+
});
101+
102+
it('renders with truncated address when address prop provided', () => {
103+
const { getByTestId } = renderWithProvider(() => (
104+
<CardImage
105+
type={CardType.VIRTUAL}
106+
status={CardStatus.ACTIVE}
107+
address="0x1234567890123456789012345678901234567890"
108+
testID="card-with-address"
109+
/>
110+
));
111+
112+
expect(getByTestId('card-with-address')).toBeOnTheScreen();
113+
});
114+
115+
it('renders without address when address prop not provided', () => {
116+
const { getByTestId } = renderWithProvider(() => (
117+
<CardImage
118+
type={CardType.VIRTUAL}
119+
status={CardStatus.ACTIVE}
120+
testID="card-without-address"
61121
/>
62122
));
63123

64-
expect(toJSON()).toMatchSnapshot();
124+
expect(getByTestId('card-without-address')).toBeOnTheScreen();
65125
});
66126

67127
it.each([CardType.VIRTUAL, CardType.PHYSICAL, CardType.METAL] as const)(
68-
'renders %s card type without errors',
128+
'renders %s card type with ACTIVE status',
69129
(cardType) => {
70-
const { toJSON } = renderWithProvider(() => (
71-
<CardImage type={cardType} />
130+
const { getByTestId } = renderWithProvider(() => (
131+
<CardImage
132+
type={cardType}
133+
status={CardStatus.ACTIVE}
134+
testID={`${cardType}-card`}
135+
/>
136+
));
137+
138+
expect(getByTestId(`${cardType}-card`)).toBeOnTheScreen();
139+
},
140+
);
141+
142+
it.each([CardStatus.ACTIVE, CardStatus.FROZEN, CardStatus.BLOCKED] as const)(
143+
'renders VIRTUAL card with %s status',
144+
(status) => {
145+
const { getByTestId } = renderWithProvider(() => (
146+
<CardImage
147+
type={CardType.VIRTUAL}
148+
status={status}
149+
testID={`card-${status}`}
150+
/>
72151
));
73152

74-
expect(toJSON()).toBeDefined();
153+
expect(getByTestId(`card-${status}`)).toBeOnTheScreen();
75154
},
76155
);
156+
157+
it('renders with custom SVG properties', () => {
158+
const { getByTestId } = renderWithProvider(() => (
159+
<CardImage
160+
type={CardType.VIRTUAL}
161+
status={CardStatus.ACTIVE}
162+
width={200}
163+
height={100}
164+
testID="custom-card-image"
165+
/>
166+
));
167+
168+
expect(getByTestId('custom-card-image')).toBeOnTheScreen();
169+
});
77170
});

0 commit comments

Comments
 (0)