Skip to content

Commit bfb9e9f

Browse files
chore(runway): cherry-pick feat(card): cp-7.64.0 Onboarding and Metal Card flow fixes (#25538)
- feat(card): cp-7.64.0 Onboarding and Metal Card flow fixes (#25473) <!-- 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** This PR includes multiple improvements and bug fixes for the Card feature: ### Sign Up Flow Improvements - **Removed confirm password field**: Simplified the sign up form by removing the redundant password confirmation step - **Added password visibility toggle**: Users can now show/hide their password using an eye icon - **Reordered form fields**: Country selector is now displayed first, followed by email and password - **Updated copy**: Changed "Apply now" button text to "Setup now" and updated password description ### DaimoPay Environment Toggle - **Added experimental setting**: New toggle in Experimental Settings to switch between DaimoPay demo and production environments (only visible in non-production builds) - **Refactored environment detection**: `getDaimoEnvironment` now accepts a parameter instead of checking `__DEV__` - **Fixed payment flow**: Payment polling now uses `orderId` instead of `payId` for correct status tracking - **Improved payment completion handling**: Removed premature navigation on `paymentCompleted` WebView event - now relies on polling to confirm actual completion ### Card Home Fixes - **Fixed manage card options visibility**: Added `needsSetup` condition to hide manage card, metal card order, and travel options when user hasn't completed card setup - **Fixed balance display for non-US locales**: Fixed an issue where fiat balance was displaying 100x the correct value (e.g., $55 instead of $0.55) for locales that use comma as decimal separator (Brazilian Portuguese, German, etc.) ### Token Balance Fixes - **Fixed potential balance mismatch**: Changed `useTokensWithBalance` from index-based array lookup to address-based Map lookup to prevent balance mismatches when tokens are filtered ### Spending Limit Screen Fix - **Fixed asset selection flickering**: Resolved an issue where selecting a different asset from the AssetSelectionBottomSheet would immediately revert to the original asset ### Other Changes - **PersonalDetails**: Removed fallback for nationality field (no longer uses `countryOfResidence` as fallback) - **CardSDK**: Added `location` parameter to `getRegistrationStatus` method - **Types**: Added `requestId` to `CreateOrderResponse` and `STARTED` to `OrderStatus` ## **Changelog** CHANGELOG entry: Improved Card sign up flow by removing confirm password field and adding password visibility toggle. Fixed balance display for non-US locales, fixed asset selection flickering on Spending Limit screen, and fixed manage card options visibility for users who haven't completed setup. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card sign up flow Scenario: User signs up for card Given user opens the Card sign up screen When user views the form Then country selector should be displayed first And email field should be displayed second And password field should be displayed third And there should be no confirm password field And password field should have a visibility toggle icon Feature: Card balance display with locale formatting Scenario: User views correct balance with Brazilian locale Given user has device set to Brazilian Portuguese locale And user has 0.55 USDC in their wallet When user navigates to Card Home Then balance should display as "US$ 0,55" (not "US$ 55,00") Feature: Spending limit asset selection Scenario: User changes asset on spending limit screen Given user is on the Spending Limit screen with USDC selected When user taps "Other" to select a different asset And user selects USDT from the asset selection bottom sheet Then USDT should remain selected (not revert to USDC) Feature: Card Home manage options visibility Scenario: User sees manage options only after setup Given user has not completed card setup (needs delegation) When user views Card Home Then "Manage Card", "Order Metal Card", and "Travel" options should be hidden Feature: DaimoPay environment toggle (non-production only) Scenario: Developer toggles DaimoPay demo mode Given user is on a non-production build And user navigates to Settings > Experimental When user toggles "Use DaimoPay demo environment" Then DaimoPay should use the demo environment for payments ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** First case: Demo disabled. The order fails because a payment was already completed. Second case: Demo enabled. The demo flow starts successfully. https://github.com/user-attachments/assets/3093aae7-276d-4351-ae8f-aee70d412f9d <!-- [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] > **Medium Risk** > Medium risk due to changes across Card onboarding, DaimoPay payment creation/polling/navigation, and Redux state used to select environments and locations; could affect checkout completion and onboarding flows if miswired. > > **Overview** > Improves Card onboarding UX by **removing the confirm-password field**, reordering the Sign Up form (country → email → password), adding **password visibility toggles** (Sign Up + Login), updating onboarding copy, and disabling app auto-lock while the onboarding navigator is mounted. > > Refactors DaimoPay to support a **demo/production toggle** (new `card.isDaimoDemo` + Experimental Settings switch shown only in non-production builds) and to **poll by `orderId`** in production (while demo navigates immediately). This threads `orderId` through `ReviewOrder` → `DaimoPayModal`, updates `DaimoPayService` to return `{ payId, orderId }` (with `requestId` support), and tightens event/origin handling. > > Fixes a few Card flow issues: hides manage-card actions until setup is complete, parses locale-formatted fiat strings correctly when deriving balances, prevents spending-limit token selection from being overwritten after returning from the bottom sheet, removes nationality fallback in `PersonalDetails`, and passes `userCardLocation` into `CardSDK.getRegistrationStatus`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 32d224d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [7c2216d](7c2216d) Co-authored-by: Bruno Nascimento <brunonascimentodev@gmail.com>
1 parent 6554660 commit bfb9e9f

30 files changed

Lines changed: 819 additions & 535 deletions

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,44 @@ describe('CardAuthentication Component', () => {
243243
});
244244
});
245245

246+
describe('Login Step - Password Visibility Toggle', () => {
247+
it('renders the password visibility toggle button', () => {
248+
render();
249+
250+
expect(
251+
screen.getByTestId('password-visibility-toggle'),
252+
).toBeOnTheScreen();
253+
});
254+
255+
it('has password hidden by default', () => {
256+
render();
257+
const passwordInput = screen.getByTestId('password-field');
258+
259+
expect(passwordInput).toHaveProp('secureTextEntry', true);
260+
});
261+
262+
it('shows password when visibility toggle is pressed', () => {
263+
render();
264+
const passwordInput = screen.getByTestId('password-field');
265+
const toggleButton = screen.getByTestId('password-visibility-toggle');
266+
267+
fireEvent.press(toggleButton);
268+
269+
expect(passwordInput).toHaveProp('secureTextEntry', false);
270+
});
271+
272+
it('hides password again when visibility toggle is pressed twice', () => {
273+
render();
274+
const passwordInput = screen.getByTestId('password-field');
275+
const toggleButton = screen.getByTestId('password-visibility-toggle');
276+
277+
fireEvent.press(toggleButton);
278+
fireEvent.press(toggleButton);
279+
280+
expect(passwordInput).toHaveProp('secureTextEntry', true);
281+
});
282+
});
283+
246284
describe('Login Step - Login Functionality', () => {
247285
it('calls login with correct parameters for international location', async () => {
248286
render();

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@ import {
66
FontWeight,
77
Text,
88
TextVariant,
9-
} from '@metamask/design-system-react-native';
10-
import Icon, {
9+
Icon,
1110
IconName,
1211
IconSize,
13-
} from '../../../../../component-library/components/Icons/Icon';
12+
} from '@metamask/design-system-react-native';
1413
import TextField, {
1514
TextFieldSize,
1615
} from '../../../../../component-library/components/Form/TextField';
@@ -30,7 +29,10 @@ import { strings } from '../../../../../../locales/i18n';
3029
import Logger from '../../../../../util/Logger';
3130
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
3231
import { useDispatch } from 'react-redux';
33-
import { setOnboardingId } from '../../../../../core/redux/slices/card';
32+
import {
33+
setOnboardingId,
34+
setUserCardLocation,
35+
} from '../../../../../core/redux/slices/card';
3436
import { CardActions, CardScreens } from '../../util/metrics';
3537
import OnboardingStep from '../../components/Onboarding/OnboardingStep';
3638
import { useTailwind } from '@metamask/design-system-twrnc-preset';
@@ -49,6 +51,7 @@ const CardAuthentication = () => {
4951
const [step, setStep] = useState<'login' | 'otp'>('login');
5052
const [email, setEmail] = useState('');
5153
const [password, setPassword] = useState('');
54+
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
5255
const [loading, setLoading] = useState(false);
5356
const [location, setLocation] = useState<CardLocation>('international');
5457
const [otpData, setOtpData] = useState<{
@@ -187,6 +190,7 @@ const CardAuthentication = () => {
187190
}
188191

189192
if (loginResponse?.phase) {
193+
dispatch(setUserCardLocation(location));
190194
dispatch(setOnboardingId(loginResponse.userId));
191195
navigation.reset({
192196
index: 0,
@@ -432,11 +436,22 @@ const CardAuthentication = () => {
432436
maxLength={255}
433437
returnKeyType={'done'}
434438
onSubmitEditing={() => performLogin()}
435-
secureTextEntry
439+
secureTextEntry={!isPasswordVisible}
436440
accessibilityLabel={strings(
437441
'card.card_authentication.password_label',
438442
)}
439443
testID="password-field"
444+
endAccessory={
445+
<TouchableOpacity
446+
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
447+
testID="password-visibility-toggle"
448+
>
449+
<Icon
450+
name={isPasswordVisible ? IconName.EyeSlash : IconName.Eye}
451+
size={IconSize.Md}
452+
/>
453+
</TouchableOpacity>
454+
}
440455
/>
441456
</Box>
442457
</>
@@ -449,6 +464,7 @@ const CardAuthentication = () => {
449464
handleOtpValueChange,
450465
handlePasswordChange,
451466
handleResendOtp,
467+
isPasswordVisible,
452468
location,
453469
otpError,
454470
otpLoading,

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

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -502,17 +502,18 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l
502502
testID="international-location-box"
503503
>
504504
<SvgMock
505-
color="#121314"
506505
fill="currentColor"
507-
height={24}
508506
name="Global"
509507
style={
510-
{
511-
"height": 24,
512-
"width": 24,
513-
}
508+
[
509+
{
510+
"color": "#121314",
511+
"height": 24,
512+
"width": 24,
513+
},
514+
undefined,
515+
]
514516
}
515-
width={24}
516517
/>
517518
<Text
518519
accessibilityRole="text"
@@ -837,6 +838,34 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l
837838
value=""
838839
/>
839840
</View>
841+
<View
842+
style={
843+
{
844+
"marginLeft": 8,
845+
}
846+
}
847+
testID="textfield-endacccessory"
848+
>
849+
<TouchableOpacity
850+
onPress={[Function]}
851+
testID="password-visibility-toggle"
852+
>
853+
<SvgMock
854+
fill="currentColor"
855+
name="Eye"
856+
style={
857+
[
858+
{
859+
"color": "#121314",
860+
"height": 20,
861+
"width": 20,
862+
},
863+
undefined,
864+
]
865+
}
866+
/>
867+
</TouchableOpacity>
868+
</View>
840869
</View>
841870
</View>
842871
</View>

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

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,41 +1116,44 @@ const CardHome = () => {
11161116
/>
11171117
)}
11181118
</Box>
1119-
{!isLoading && !cardSetupState.isKYCPending && !isCardProvisioning && (
1120-
<>
1121-
<ManageCardListItem
1122-
title={strings('card.card_home.manage_card_options.manage_card')}
1123-
description={strings(
1124-
'card.card_home.manage_card_options.advanced_card_management_description',
1125-
)}
1126-
rightIcon={IconName.Export}
1127-
onPress={navigateToCardPage}
1128-
testID={CardHomeSelectors.ADVANCED_CARD_MANAGEMENT_ITEM}
1129-
/>
1130-
{isUserEligibleForMetalCard && (
1119+
{!isLoading &&
1120+
!cardSetupState.isKYCPending &&
1121+
!cardSetupState.needsSetup &&
1122+
!isCardProvisioning && (
1123+
<>
11311124
<ManageCardListItem
1132-
title={strings(
1133-
'card.card_home.manage_card_options.order_metal_card',
1134-
)}
1125+
title={strings('card.card_home.manage_card_options.manage_card')}
11351126
description={strings(
1136-
'card.card_home.manage_card_options.order_metal_card_description',
1127+
'card.card_home.manage_card_options.advanced_card_management_description',
11371128
)}
1138-
rightIcon={IconName.ArrowRight}
1139-
onPress={orderMetalCardAction}
1140-
testID={CardHomeSelectors.ORDER_METAL_CARD_ITEM}
1129+
rightIcon={IconName.Export}
1130+
onPress={navigateToCardPage}
1131+
testID={CardHomeSelectors.ADVANCED_CARD_MANAGEMENT_ITEM}
11411132
/>
1142-
)}
1143-
<ManageCardListItem
1144-
title={strings('card.card_home.manage_card_options.travel_title')}
1145-
description={strings(
1146-
'card.card_home.manage_card_options.travel_description',
1133+
{isUserEligibleForMetalCard && (
1134+
<ManageCardListItem
1135+
title={strings(
1136+
'card.card_home.manage_card_options.order_metal_card',
1137+
)}
1138+
description={strings(
1139+
'card.card_home.manage_card_options.order_metal_card_description',
1140+
)}
1141+
rightIcon={IconName.ArrowRight}
1142+
onPress={orderMetalCardAction}
1143+
testID={CardHomeSelectors.ORDER_METAL_CARD_ITEM}
1144+
/>
11471145
)}
1148-
rightIcon={IconName.Export}
1149-
onPress={navigateToTravelPage}
1150-
testID={CardHomeSelectors.TRAVEL_ITEM}
1151-
/>
1152-
</>
1153-
)}
1146+
<ManageCardListItem
1147+
title={strings('card.card_home.manage_card_options.travel_title')}
1148+
description={strings(
1149+
'card.card_home.manage_card_options.travel_description',
1150+
)}
1151+
rightIcon={IconName.Export}
1152+
onPress={navigateToTravelPage}
1153+
testID={CardHomeSelectors.TRAVEL_ITEM}
1154+
/>
1155+
</>
1156+
)}
11541157
{isAuthenticated && !isLoading && (
11551158
<>
11561159
<Box

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ jest.mock('../../sdk', () => ({
4949
}),
5050
}));
5151

52+
jest.mock('react-redux', () => ({
53+
useSelector: jest.fn(() => false),
54+
useDispatch: jest.fn(() => jest.fn()),
55+
}));
56+
5257
jest.mock('../../../../hooks/useMetrics', () => ({
5358
useMetrics: () => ({
5459
trackEvent: mockTrackEvent,

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import DaimoPayService from '../../services/DaimoPayService';
2323
import Logger from '../../../../../util/Logger';
2424
import { useCardSDK } from '../../sdk';
2525
import { useParams } from '../../../../../util/navigation/navUtils';
26+
import { useSelector } from 'react-redux';
27+
import { selectIsDaimoDemo } from '../../../../../core/redux/slices/card';
2628

2729
export interface ShippingAddress {
2830
line1: string;
@@ -51,7 +53,7 @@ const ReviewOrder = () => {
5153
useParams<ReviewOrderParams>();
5254

5355
const { sdk: cardSDK } = useCardSDK();
54-
56+
const isDaimoDemo = useSelector(selectIsDaimoDemo);
5557
const [isCreatingPayment, setIsCreatingPayment] = useState(false);
5658
const [paymentError, setPaymentError] = useState<string | null>(null);
5759

@@ -135,11 +137,16 @@ const ReviewOrder = () => {
135137
try {
136138
const response = await DaimoPayService.createPayment({
137139
cardSDK: cardSDK ?? undefined,
140+
isDaimoDemo,
138141
});
139142

140143
navigate(Routes.CARD.MODALS.ID, {
141144
screen: Routes.CARD.MODALS.DAIMO_PAY,
142-
params: { payId: response.payId, fromUpgrade },
145+
params: {
146+
payId: response.payId,
147+
orderId: response.orderId,
148+
fromUpgrade,
149+
},
143150
});
144151
} catch (error) {
145152
Logger.error(
@@ -149,7 +156,14 @@ const ReviewOrder = () => {
149156
setPaymentError(strings('card.review_order.payment_creation_error'));
150157
setIsCreatingPayment(false);
151158
}
152-
}, [navigate, trackEvent, createEventBuilder, cardSDK, fromUpgrade]);
159+
}, [
160+
navigate,
161+
trackEvent,
162+
createEventBuilder,
163+
cardSDK,
164+
fromUpgrade,
165+
isDaimoDemo,
166+
]);
153167

154168
const renderOrderItem = useCallback((item: OrderItem, index: number) => {
155169
const isTotal = item.label === strings('card.review_order.total');

0 commit comments

Comments
 (0)