From ac259dc76bffa37d0ace7c13e10cd6ac5ab11b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Tue, 26 May 2026 15:29:15 -0300 Subject: [PATCH 01/12] feat(predict): add portfolio feature flag (#30600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds remote-config support for the new Predict portfolio feature flag introduced by PRED-898. This PR wires `predictPortfolio` through the existing Predict feature flag pipeline: - Adds the typed flag shape: `{ enabled: boolean; minimumVersion: string }` - Defaults malformed or missing config to `{ enabled: false, minimumVersion: '' }` - Parses the flag through the Predict schema/resolver flow - Applies existing version-gated feature flag validation - Exposes `selectPredictPortfolioEnabledFlag` - Covers default, enabled, disabled, malformed, progressive rollout, and minimum-version behavior in unit tests This PR does not gate UI or routes yet. It only adds the flag infrastructure needed for the portfolio module and positions screen to consume later. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [PRED-898](https://consensyssoftware.atlassian.net/browse/PRED-898) ## **Manual testing steps** ```gherkin Feature: Predict portfolio remote feature flag Scenario: portfolio flag is absent or disabled Given the Predict remote config does not include predictPortfolio When the Predict feature flags are resolved Then selectPredictPortfolioEnabledFlag returns false Scenario: portfolio flag is enabled for the current app version Given the Predict remote config includes predictPortfolio enabled with a passing minimumVersion When the Predict feature flags are resolved Then selectPredictPortfolioEnabledFlag returns true Scenario: portfolio flag config is malformed Given the Predict remote config includes malformed predictPortfolio values When the Predict feature flags are resolved Then selectPredictPortfolioEnabledFlag returns false ``` ## **Screenshots/Recordings** N/A. This is feature flag plumbing only and has no user-visible UI changes. ### **Before** N/A ### **After** N/A ## **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 - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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. ## Test plan ```bash node .yarn/releases/yarn-4.14.1.cjs jest app/components/UI/Predict/schemas/flags.test.ts app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts app/components/UI/Predict/selectors/featureFlags/index.test.ts ``` Result: 3 test suites passed, 139 tests passed. [PRED-898]: https://consensyssoftware.atlassian.net/browse/PRED-898?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Flag-only plumbing with conservative false defaults; no user-facing routes or trading behavior changes in this diff. > > **Overview** > Adds **Predict portfolio** remote feature flag plumbing so later work can gate portfolio UI and routes without changing this PR. > > The remote key `predictPortfolio` is resolved through the existing version-gated boolean path in `resolvePredictFeatureFlags`, surfaced on `PredictFeatureFlags` as `predictPortfolioEnabled`, and exposed via `selectPredictPortfolioEnabledFlag`. Defaults stay **off** when the flag is missing, invalid, below `minimumVersion`, or not version-gated (including empty `minimumVersion`). Unit tests cover resolver and selector behavior (enabled/disabled, malformed config, progressive rollout unwrap). > > **No navigation or UI is gated yet**—only flag infrastructure and tests. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8081140a5bdfe721ad8cc132e7ef165d4a82a954. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../polymarket/PolymarketProvider.test.ts | 1 + .../selectors/featureFlags/index.test.ts | 121 ++++++++++++++++ .../Predict/selectors/featureFlags/index.ts | 5 + app/components/UI/Predict/types/flags.ts | 1 + .../utils/resolvePredictFeatureFlags.test.ts | 129 ++++++++++++++++++ .../utils/resolvePredictFeatureFlags.ts | 4 + 6 files changed, 261 insertions(+) diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 694a99dd6b5..be2fbeb1f45 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -348,6 +348,7 @@ const defaultFeatureFlags: PredictFeatureFlags = { fakOrdersEnabled: false, predictWithAnyTokenEnabled: false, predictUpDownEnabled: false, + predictPortfolioEnabled: false, predictHomepageDiscoveryNbaChampionEnabled: true, predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG, }; diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 8861b53cebd..f2e65ac5176 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -10,6 +10,7 @@ import { selectPredictHomeFeaturedVariant, selectPredictHomepageDiscoveryNbaChampionEnabledFlag, selectPredictHotTabFlag, + selectPredictPortfolioEnabledFlag, selectPredictUpDownEnabledFlag, selectPredictWithAnyTokenEnabledFlag, selectPredictWorldCupConfig, @@ -1544,6 +1545,126 @@ describe('Predict Feature Flag Selectors', () => { }); }); + describe('selectPredictPortfolioEnabledFlag', () => { + it('returns false when flag is missing', () => { + const result = selectPredictPortfolioEnabledFlag(mockedEmptyFlagsState); + + expect(result).toBe(false); + }); + + it('returns true when flag is enabled and version requirement is met', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictPortfolio: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictPortfolioEnabledFlag(state); + + expect(result).toBe(true); + }); + + it('returns false when flag is disabled', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictPortfolio: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictPortfolioEnabledFlag(state); + + expect(result).toBe(false); + }); + + it('returns false when flag is malformed', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictPortfolio: { + enabled: 'true', + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictPortfolioEnabledFlag(state); + + expect(result).toBe(false); + }); + + it('returns false when app version is below minimum required', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictPortfolio: { + enabled: true, + minimumVersion: '99.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictPortfolioEnabledFlag(state); + + expect(result).toBe(false); + }); + + it('returns false when minimumVersion is the default empty string', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictPortfolio: { + enabled: true, + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictPortfolioEnabledFlag(state); + + expect(result).toBe(false); + }); + }); + describe('selectPredictHomepageDiscoveryNbaChampionEnabledFlag', () => { it('returns false when the remote flag is disabled', () => { mockHasMinimumRequiredVersion.mockReturnValue(true); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index 26126f4ad7b..7cd3ccedd50 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -178,6 +178,11 @@ export const selectPredictWorldCupScreenEnabledFlag = createSelector( (config) => config.enabled && config.showWorldCupScreen, ); +export const selectPredictPortfolioEnabledFlag = createSelector( + selectPredictFeatureFlags, + (flags) => flags.predictPortfolioEnabled, +); + export const selectPredictFeaturedCarouselEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 668073025b3..4490f87cbaa 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -55,6 +55,7 @@ export interface PredictFeatureFlags { predictUpDownEnabled: boolean; predictHomepageDiscoveryNbaChampionEnabled: boolean; predictWorldCup: PredictWorldCupConfig; + predictPortfolioEnabled: boolean; } export interface PredictHotTabFlag extends VersionGatedFeatureFlag { diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts index 9711b3ee1c1..ff9a93020d2 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts @@ -32,6 +32,7 @@ describe('resolvePredictFeatureFlags', () => { fakOrdersEnabled: false, predictWithAnyTokenEnabled: false, predictUpDownEnabled: false, + predictPortfolioEnabled: false, predictHomepageDiscoveryNbaChampionEnabled: true, predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG, }); @@ -323,6 +324,134 @@ describe('resolvePredictFeatureFlags', () => { }); }); + describe('predictPortfolioEnabled', () => { + it('returns false when flag is missing', () => { + const result = resolvePredictFeatureFlags({}); + + expect(result.predictPortfolioEnabled).toBe(false); + }); + + it('returns true when enabled and version gate passes', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if ( + flag && + typeof flag === 'object' && + 'minimumVersion' in flag && + !('leagues' in flag) && + !('seriesId' in flag) + ) { + return true; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictPortfolio: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + }); + + expect(result.predictPortfolioEnabled).toBe(true); + }); + + it('returns false when flag is disabled', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if ( + flag && + typeof flag === 'object' && + 'minimumVersion' in flag && + !('leagues' in flag) && + !('seriesId' in flag) + ) { + return false; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictPortfolio: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + }); + + expect(result.predictPortfolioEnabled).toBe(false); + }); + + it('returns false when flag is malformed', () => { + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictPortfolio: { + enabled: 'true', + minimumVersion: '1.0.0', + }, + }, + }); + + expect(result.predictPortfolioEnabled).toBe(false); + }); + + it('returns false when version gate fails', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if ( + flag && + typeof flag === 'object' && + 'minimumVersion' in flag && + !('leagues' in flag) && + !('seriesId' in flag) + ) { + return false; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictPortfolio: { + enabled: true, + minimumVersion: '99.0.0', + }, + }, + }); + + expect(result.predictPortfolioEnabled).toBe(false); + }); + + it('unwraps progressive rollout shape', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if ( + flag && + typeof flag === 'object' && + 'minimumVersion' in flag && + !('leagues' in flag) && + !('seriesId' in flag) + ) { + return true; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictPortfolio: { + name: 'group-a', + value: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + }, + }); + + expect(result.predictPortfolioEnabled).toBe(true); + }); + }); + describe('extendedSportsMarketsLeagues', () => { it('returns empty array when flag is missing', () => { const result = resolvePredictFeatureFlags({}); diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts index c4bf90e3a08..1c3238edcbc 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts @@ -99,6 +99,9 @@ export function resolvePredictFeatureFlags( const predictUpDownEnabled = resolveVersionGatedBooleanFlag( flags.predictUpDown, ); + const predictPortfolioEnabled = resolveVersionGatedBooleanFlag( + flags.predictPortfolio, + ); const predictHomepageDiscoveryNbaChampionEnabled = resolveVersionGatedBooleanFlag( flags.predictHomepageDiscoveryNbaChampionEnabled, @@ -125,6 +128,7 @@ export function resolvePredictFeatureFlags( fakOrdersEnabled, predictWithAnyTokenEnabled, predictUpDownEnabled, + predictPortfolioEnabled, predictHomepageDiscoveryNbaChampionEnabled, predictWorldCup, }; From 34581a90ad383fff96be215aff5a3e3d569e01ff Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Tue, 26 May 2026 15:46:26 -0300 Subject: [PATCH 02/12] feat(card): money-account UX polishes across Card Home, Asset Selection, and Spending Limit (#30571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Polishes the Money Account integration across the Card surface so that money-account funding sources are identified, labeled, and managed consistently from Card Home through Spending Limit. What changed: - **`CardFundingToken.isMoneyAccountEntry` flag** baked at the selector layer (`selectCardPrimaryToken`, `selectCardFundingTokens`, `selectCardAvailableTokens`, `selectCardLineaUsdcToken`) via a pure `isMoneyAccountEntry(walletAddress, moneyAccounts)` predicate. All consumers now read one boolean instead of recomputing the comparison; future Veda-vault detection becomes a one-line change in the predicate. - **Card Home** — SVG hex address on the card art is replaced with the localized `"Money account"` label when `primaryToken.isMoneyAccountEntry` is true; `truncateAddress` no longer mangles non-hex input. - **Add Funds** — when the primary funding source is a money account, the button stays enabled and routes to the existing `MoneyAddMoneySheet` (`Routes.MONEY.MODALS.ADD_MONEY_SHEET`) instead of going through swap / deposit checks. - **Asset Selection sheet** — per-row hex address is substituted with the money-account label whenever the row's token is a money-account entry (covers both the primary-source case and other rows). - **Spending Limit** introduces a fourth `flow` value `'enable_card'` (Card Home "Enable Card" CTA) so the screen can behave like onboarding for token + Money-Account logic without triggering onboarding-only chrome (hidden back button, replace-style routing). Entry-point flow labels were also corrected: `manageSpendingLimitAction` → `'manage'`, the `AssetSelectionBottomSheet` NotEnabled tap → `'enable'`. - **Spending Limit lock semantics** — new `isMoneyAccountLocked` boolean decouples the locked state from `isMoneyAccountSource`. Lock fires only on the manage flow when `priorityToken.isMoneyAccountEntry` is true (regardless of balance). Onboarding-like flows still preselect Money Account when funded but rows remain pressable; switching the account via the picker now surfaces a Spend-and-Earn CTA. - **Money Account delegation re-submit** — `useMoneyAccountCardLinkage` exposes a new `canSubmitMoneyAccountDelegation` predicate that does not include `!isAlreadyDelegated`. `confirmLinkInBackground` now uses it, so Manage Limit can update the cap (or revoke by submitting `'0'`) on an already-delegated money-account token. `canLink` continues to gate the first-time link CTA. - **Render-tree cleanup** — Account and Token rows on the Spending Limit screen are now dedicated subcomponents (`AccountRow`, `TokenRow` under `Views/SpendingLimit/components/`), removing ~180 lines of inline IIFE JSX from the parent and stripping 12 unused imports. Chevron is shown when the row is actionable: account row when not locked; token row only when not locked AND not money-account-sourced. Reason for the change: Money Account is being introduced as an EOA-style funding source for the Card. Without these polishes, the user would see raw hex addresses, be unable to update the delegation cap after the initial link, and get inconsistent flow gating between Card Home entry points. ## **Changelog** CHANGELOG entry: Improved the Card experience for Money Account users — Card Home and Asset Selection now display "Money account" instead of the raw address, Add Funds routes to the Money Account add-funds sheet, and Manage Limit can update or revoke the delegation cap on an already-linked Money Account. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Money Account on Card Scenario: Card Home renders the Money Account label Given the user has a Money Account as primary funding source When they open Card Home Then the card art shows "Money account" instead of the raw hex address And no raw hex address appears in any Card Home surface Scenario: Add Funds routes to the Money Account sheet Given the user has a Money Account as primary funding source When they tap "Add funds" on Card Home Then the Money Account add-funds bottom sheet opens And the standard swap/deposit flow is not triggered Scenario: Asset Selection labels money-account rows Given the user has at least one money-account token in their funding list When they open the Asset Selection bottom sheet Then each money-account row shows "Money account" in place of the hex address Scenario: Manage Limit locks rows when the primary token is a Money Account Given the user has a Money Account as primary funding source When they tap "Manage limit" Then both the Account and Token rows are locked (non-pressable, no chevron) And the Spend-and-Earn CTA is not shown Scenario: Manage Limit updates the delegation cap on an already-delegated Money Account Given the user has previously linked their Money Account to the Card When they open Manage Limit, change the limit (or set it to 0 to revoke), and confirm Then the controller re-runs linkMoneyAccountCard with the new delegation amount And the success toast is shown Scenario: Enable Card from Card Home shows the Money Account CTA Given the user has funds in their Money Account And they have a Card but no delegation yet When they tap "Enable Card" on Card Home Then Money Account is preselected as the source And the back button is visible And switching to another account via the picker reveals the "Spend & Earn" CTA Scenario: Enabling a NotEnabled token from Asset Selection shows the Money Account CTA Given the user has funds in their Money Account When they tap a NotEnabled token in the Asset Selection sheet Then Spending Limit opens with that token preselected And the "Spend & Earn" CTA is visible And tapping it switches the source to Money Account ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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. --- > [!NOTE] > **Medium Risk** > Changes card funding navigation, delegation submit paths, and spending-limit flow gating across several user-facing entry points; well-covered by tests but touches money-account linkage behavior. > > **Overview** > This PR unifies **Money Account** as a Card funding source end-to-end: selectors set **`isMoneyAccountEntry`** on funding tokens (via `isMoneyAccountEntry(walletAddress, moneyAccounts)`), and Card surfaces use that flag instead of re-deriving wallet matches. > > **Card Home & funding actions:** The card art shows the localized **Money account** label instead of hex when the primary token is a money-account entry; **`truncateAddress`** leaves non-hex labels intact. **Add funds** stays enabled without swaps and opens **`MoneyAddMoneySheet`**. **Enable card** navigates to Spending Limit with **`flow: 'enable_card'`**; **Manage spending limit** uses **`flow: 'manage'`** (tests/views aligned). > > **Spending Limit:** New **`enable_card`** flow separates “enable card from home” from onboarding/manage. **`isMoneyAccountLocked`** locks account/token rows only on **manage** when the priority token is a money-account entry; onboarding-like flows keep rows pressable and adjust Money Account CTA / balance-loading rules. Account/token UI moves into **`AccountRow`** / **`TokenRow`**. > > **Delegation:** **`canSubmitMoneyAccountDelegation`** allows **`confirmLinkInBackground`** when already delegated so users can update cap or revoke (`'0'`). > > **Asset selection:** NotEnabled tokens open Spending Limit with **`flow: 'enable'`**; rows show the money-account label instead of truncated hex when applicable. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9e2c14f63dc28271037335877604daa03c715021. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Card/Views/CardHome/CardHome.test.tsx | 90 ++++++++- .../UI/Card/Views/CardHome/CardHome.tsx | 5 +- .../Views/CardHome/CardHome.view.test.tsx | 4 +- .../components/CardActionsButtons.tsx | 4 +- .../CardHome/hooks/useCardHomeActions.ts | 11 +- .../UI/Card/Views/Cashback/Cashback.test.tsx | 3 + .../SpendingLimit/SpendingLimit.test.tsx | 68 ++++++- .../SpendingLimit/SpendingLimit.testIds.ts | 1 + .../Views/SpendingLimit/SpendingLimit.tsx | 188 ++---------------- .../SpendingLimit/components/AccountRow.tsx | 142 +++++++++++++ .../components/SpendAndEarnPromoCard.tsx | 2 +- .../SpendingLimit/components/TokenRow.tsx | 174 ++++++++++++++++ .../Views/SpendingLimit/components/index.ts | 6 + .../AssetSelectionBottomSheet.test.tsx | 62 +++++- .../AssetSelectionBottomSheet.tsx | 28 +-- .../hooks/useMoneyAccountCardLinkage.test.tsx | 23 +++ .../Card/hooks/useMoneyAccountCardLinkage.tsx | 14 +- .../UI/Card/hooks/useSpendingLimit.test.ts | 161 +++++++++++++-- .../UI/Card/hooks/useSpendingLimit.ts | 30 ++- app/components/UI/Card/types.ts | 1 + .../UI/Card/util/isMoneyAccountEntry.test.ts | 76 +++++++ .../UI/Card/util/isMoneyAccountEntry.ts | 36 ++++ .../UI/Card/util/toCardTokenAllowance.test.ts | 17 ++ .../UI/Card/util/toCardTokenAllowance.ts | 6 +- .../UI/Card/util/truncateAddress.test.ts | 32 ++- .../UI/Card/util/truncateAddress.ts | 24 ++- app/selectors/cardController.test.ts | 148 +++++++++++++- app/selectors/cardController.ts | 75 +++++-- 28 files changed, 1167 insertions(+), 264 deletions(-) create mode 100644 app/components/UI/Card/Views/SpendingLimit/components/AccountRow.tsx create mode 100644 app/components/UI/Card/Views/SpendingLimit/components/TokenRow.tsx create mode 100644 app/components/UI/Card/util/isMoneyAccountEntry.test.ts create mode 100644 app/components/UI/Card/util/isMoneyAccountEntry.ts diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index be42e7d32bd..e6211ea931a 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -1306,6 +1306,84 @@ describe('CardHome Component', () => { }); }); + describe('Money Account spending source', () => { + const moneyAccountPriorityToken = { + ...mockPriorityToken, + isMoneyAccountEntry: true, + } as typeof mockPriorityToken; + + it('passes the Money Account i18n label as the address to the card image when authenticated', () => { + setupMockSelectors({ isAuthenticated: true }); + setupLoadCardDataMock({ + priorityToken: moneyAccountPriorityToken, + allTokens: [moneyAccountPriorityToken], + isAuthenticated: true, + }); + + render(); + + const cardImage = screen.getByTestId( + CardHomeSelectors.CARD_WALLET_ADDRESS, + ); + // The SVG `Svg` element receives `address` via `{...props}`; this is + // the same prop that drives the rendered SVG `` content. In + // the test environment `strings()` returns the i18n key. + expect(cardImage.props.address).toBe( + 'card.card_spending_limit.money_account_label', + ); + }); + + it('passes the truncated wallet hex (not the Money Account label) when the primary token is not a money account entry', () => { + setupMockSelectors({ isAuthenticated: true }); + const walletPriorityToken = { + ...mockPriorityToken, + isMoneyAccountEntry: false, + } as typeof mockPriorityToken; + setupLoadCardDataMock({ + priorityToken: walletPriorityToken, + allTokens: [mockPriorityToken], + isAuthenticated: true, + }); + + render(); + + const cardImage = screen.getByTestId( + CardHomeSelectors.CARD_WALLET_ADDRESS, + ); + // CardImage truncates the hex; what matters here is that the Money + // Account label is NOT used when the flag is false. + expect(cardImage.props.address).not.toBe( + 'card.card_spending_limit.money_account_label', + ); + }); + + it('navigates to MoneyAddMoneySheet and skips switchToFundingAccountIfNeeded when add funds is pressed', async () => { + setupLoadCardDataMock({ + priorityToken: moneyAccountPriorityToken, + allTokens: [moneyAccountPriorityToken], + }); + mockSetSelectedAddress.mockClear(); + mockOpenSwaps.mockClear(); + mockNavigate.mockClear(); + + render(); + + fireEvent.press(screen.getByTestId(CardHomeSelectors.ADD_FUNDS_BUTTON)); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('MoneyModals', { + screen: 'MoneyAddMoneySheet', + }); + }); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'CardModals', + expect.anything(), + ); + expect(mockOpenSwaps).not.toHaveBeenCalled(); + expect(mockSetSelectedAddress).not.toHaveBeenCalled(); + }); + }); + it('calls navigateToTravelPage when travel item is pressed', async () => { // TRAVEL_ITEM requires isFullySetUp (isAuthenticated + card + no setup actions) setupMockSelectors({ isAuthenticated: true }); @@ -3501,7 +3579,7 @@ describe('CardHome Component', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.SPENDING_LIMIT, expect.objectContaining({ - flow: 'manage', + flow: 'enable_card', }), ); }); @@ -5804,7 +5882,7 @@ describe('CardHome Component', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.SPENDING_LIMIT, expect.objectContaining({ - flow: 'manage', + flow: 'enable_card', }), ); }); @@ -5860,7 +5938,7 @@ describe('CardHome Component', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.SPENDING_LIMIT, expect.objectContaining({ - flow: 'manage', + flow: 'enable_card', }), ); }); @@ -5916,7 +5994,7 @@ describe('CardHome Component', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.SPENDING_LIMIT, expect.objectContaining({ - flow: 'manage', + flow: 'enable_card', }), ); }); @@ -5972,7 +6050,7 @@ describe('CardHome Component', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.SPENDING_LIMIT, expect.objectContaining({ - flow: 'manage', + flow: 'enable_card', }), ); }); @@ -6017,7 +6095,7 @@ describe('CardHome Component', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.SPENDING_LIMIT, expect.objectContaining({ - flow: 'manage', + flow: 'enable_card', }), ); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 42073cdd024..441c862c998 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -320,7 +320,9 @@ const CardHome = () => { cardStatus={data?.card?.status} walletAddress={ isAuthenticated - ? data?.primaryFundingAsset?.walletAddress + ? primaryToken?.isMoneyAccountEntry + ? strings('card.card_spending_limit.money_account_label') + : data?.primaryFundingAsset?.walletAddress : undefined } /> @@ -360,6 +362,7 @@ const CardHome = () => { actions={data?.actions ?? []} isLoading={isLoading} isSwapEnabled={isSwapEnabled} + isMoneyAccountEntry={!!primaryToken?.isMoneyAccountEntry} onAddFunds={actions.addFundsAction} onEnableCard={actions.enableCardAction} /> diff --git a/app/components/UI/Card/Views/CardHome/CardHome.view.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.view.test.tsx index a045deef7af..68ace55d916 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.view.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.view.test.tsx @@ -60,7 +60,7 @@ describe('CardHome', () => { expect(params.screen).toBe(Routes.CARD.MODALS.ASSET_SELECTION); }); - it('opens Spending Limit screen with flow=enable when Manage Spending Limit button is pressed', async () => { + it('opens Spending Limit screen with flow=manage when Manage Spending Limit button is pressed', async () => { const { getByTestId, findByTestId } = renderCardHomeView({ extraRoutes: [ { @@ -79,7 +79,7 @@ describe('CardHome', () => { ); expect(paramsEl).toBeOnTheScreen(); const params = JSON.parse(paramsEl.props.children as string); - expect(params.flow).toBe('enable'); + expect(params.flow).toBe('manage'); }); it('opens Cashback screen showing balance and withdrawal button when Cashback button is pressed', async () => { diff --git a/app/components/UI/Card/Views/CardHome/components/CardActionsButtons.tsx b/app/components/UI/Card/Views/CardHome/components/CardActionsButtons.tsx index 79e5fce8ec8..349dcdbe5f4 100644 --- a/app/components/UI/Card/Views/CardHome/components/CardActionsButtons.tsx +++ b/app/components/UI/Card/Views/CardHome/components/CardActionsButtons.tsx @@ -14,6 +14,7 @@ interface CardActionsButtonsProps { actions: CardAction[]; isLoading: boolean; isSwapEnabled: boolean; + isMoneyAccountEntry?: boolean; onAddFunds: () => void; onEnableCard: () => void; } @@ -22,6 +23,7 @@ const CardActionsButtons = ({ actions, isLoading, isSwapEnabled, + isMoneyAccountEntry = false, onAddFunds, onEnableCard, }: CardActionsButtonsProps) => { @@ -64,7 +66,7 @@ const CardActionsButtons = ({ size={ButtonSize.Lg} onPress={onAddFunds} isFullWidth - isDisabled={!isSwapEnabled} + isDisabled={!isSwapEnabled && !isMoneyAccountEntry} testID={CardHomeSelectors.ADD_FUNDS_BUTTON} > {strings('card.card_home.add_funds')} diff --git a/app/components/UI/Card/Views/CardHome/hooks/useCardHomeActions.ts b/app/components/UI/Card/Views/CardHome/hooks/useCardHomeActions.ts index 21da1c1d293..c9758b859f5 100644 --- a/app/components/UI/Card/Views/CardHome/hooks/useCardHomeActions.ts +++ b/app/components/UI/Card/Views/CardHome/hooks/useCardHomeActions.ts @@ -314,6 +314,13 @@ export function useCardHomeActions({ createEventBuilder(MetaMetricsEvents.CARD_ADD_FUNDS_CLICKED).build(), ); + if (primaryToken?.isMoneyAccountEntry) { + navigation.navigate(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, + }); + return; + } + const isPriorityTokenSupportedDeposit = !!DEPOSIT_SUPPORTED_TOKENS.find( (t) => t.toLowerCase() === data?.primaryFundingAsset?.symbol?.toLowerCase(), @@ -360,7 +367,7 @@ export function useCardHomeActions({ .build(), ); navigation.navigate(Routes.CARD.SPENDING_LIMIT, { - flow: 'manage', + flow: 'enable_card', }); }, [navigation, trackEvent, createEventBuilder]); @@ -372,7 +379,7 @@ export function useCardHomeActions({ ); if (isAuthenticated) { navigation.navigate(Routes.CARD.SPENDING_LIMIT, { - flow: 'enable', + flow: 'manage', }); } else { navigation.navigate(Routes.CARD.AUTHENTICATION, { showAuthPrompt: true }); diff --git a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx index 5a620fe2cd6..b16855d70e5 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx @@ -196,6 +196,9 @@ function render(cardControllerOverrides = {}) { PreferencesController: { isIpfsGatewayEnabled: true, }, + MoneyAccountController: { + moneyAccounts: {}, + }, CardController: { selectedCountry: null, activeProviderId: 'baanx', diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx index 6d807bb18bf..7d06b15d0b6 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx @@ -277,7 +277,7 @@ jest.spyOn(Logger, 'error').mockImplementation(() => undefined); interface MockRoute { params?: { - flow?: 'manage' | 'enable' | 'onboarding'; + flow?: 'manage' | 'enable' | 'onboarding' | 'enable_card'; selectedToken?: CardFundingToken; returnedSelectedToken?: CardFundingToken; }; @@ -347,6 +347,7 @@ describe('SpendingLimit Component', () => { needsFaucet: false, isFaucetCheckLoading: false, isMoneyAccountSource: false, + isMoneyAccountLocked: false, canShowMoneyAccountCta: false, selectMoneyAccountAsSource: mockSelectMoneyAccountAsSource, moneyAccountTotalFiatFormatted: undefined as string | undefined, @@ -1059,11 +1060,24 @@ describe('SpendingLimit Component', () => { expect(screen.getByText('Money account')).toBeOnTheScreen(); }); - it('renders the locked token row (no chevron, not pressable) with the mUSD display label and fiat balance', () => { + it('renders the account row as a pressable (non-locked) row showing Money Account on onboarding-like flows', () => { mountWithMoneyAccount(); - expect(screen.getByTestId('token-row-locked')).toBeOnTheScreen(); - expect(screen.queryByTestId('token-row')).not.toBeOnTheScreen(); + expect(screen.getByTestId('account-row')).toBeOnTheScreen(); + expect(screen.queryByTestId('account-row-locked')).not.toBeOnTheScreen(); + expect(screen.getByTestId('account-row-money-account')).toBeOnTheScreen(); + + // Tapping the row opens the account picker (exits Money Account mode). + mockHandleAccountSelect.mockClear(); + fireEvent.press(screen.getByTestId('account-row')); + expect(mockHandleAccountSelect).toHaveBeenCalledTimes(1); + }); + + it('renders the token row as pressable (non-locked) with the mUSD display label and fiat balance on onboarding-like flows', () => { + mountWithMoneyAccount(); + + expect(screen.getByTestId('token-row')).toBeOnTheScreen(); + expect(screen.queryByTestId('token-row-locked')).not.toBeOnTheScreen(); expect(screen.getByText('mUSD ($12.34)')).toBeOnTheScreen(); expect(screen.queryByText('USDC on Linea')).not.toBeOnTheScreen(); }); @@ -1169,16 +1183,19 @@ describe('SpendingLimit Component', () => { expect(screen.getByText('mUSD')).toBeOnTheScreen(); }); - it('renders Money Account rows in the manage flow when Money Account is the source', () => { + it('locks both Account and Token rows on the manage flow when Money Account is the source', () => { mockUseSpendingLimit.mockReturnValue({ ...getDefaultUseSpendingLimitMock(), isMoneyAccountSource: true, + isMoneyAccountLocked: true, selectedToken: moneyAccountToken, moneyAccountTotalFiatFormatted: '$12.34', }); render({ params: { flow: 'manage' } }); + expect(screen.getByTestId('account-row-locked')).toBeOnTheScreen(); + expect(screen.queryByTestId('account-row')).not.toBeOnTheScreen(); expect(screen.getByTestId('account-row-money-account')).toBeOnTheScreen(); expect(screen.getByTestId('token-row-locked')).toBeOnTheScreen(); expect(screen.queryByTestId('token-row')).not.toBeOnTheScreen(); @@ -1188,14 +1205,14 @@ describe('SpendingLimit Component', () => { ).not.toBeOnTheScreen(); }); - it('renders the switch-back CTA in the manage flow when canShowMoneyAccountCta is true', () => { + it('renders the Money Account CTA in the enable flow when canShowMoneyAccountCta is true (NotEnabled token + funded)', () => { mockUseSpendingLimit.mockReturnValue({ ...getDefaultUseSpendingLimitMock(), isMoneyAccountSource: false, canShowMoneyAccountCta: true, }); - render({ params: { flow: 'manage' } }); + render({ params: { flow: 'enable', selectedToken: mockMUSDToken } }); expect(screen.getByTestId('use-money-account-cta')).toBeOnTheScreen(); }); @@ -1255,7 +1272,7 @@ describe('SpendingLimit Component', () => { expect(screen.getByTestId('account-row')).toBeOnTheScreen(); }); - it('still blocks the manage flow UI on the Money Account balance when linking is possible', () => { + it('does NOT block the manage flow UI on the Money Account balance even when linking is possible', () => { mockUseSpendingLimit.mockReturnValue({ ...getDefaultUseSpendingLimitMock(), isMoneyAccountBalanceLoading: true, @@ -1264,10 +1281,43 @@ describe('SpendingLimit Component', () => { render({ params: { flow: 'manage' } }); + expect( + screen.queryByTestId('spending-limit-loading-indicator'), + ).not.toBeOnTheScreen(); + expect(screen.getByTestId('account-row')).toBeOnTheScreen(); + }); + + it('shows the loading state on the enable_card flow while the Money Account balance is still resolving', () => { + mockUseSpendingLimit.mockReturnValue({ + ...getDefaultUseSpendingLimitMock(), + isMoneyAccountBalanceLoading: true, + canLinkMoneyAccount: true, + }); + + render({ params: { flow: 'enable_card' } }); + expect( screen.getByTestId('spending-limit-loading-indicator'), ).toBeOnTheScreen(); - expect(screen.queryByTestId('account-row')).not.toBeOnTheScreen(); + }); + + it('renders pressable Money Account rows (NOT locked) on the enable_card flow when Money Account is the source', () => { + mockUseSpendingLimit.mockReturnValue({ + ...getDefaultUseSpendingLimitMock(), + isMoneyAccountSource: true, + isMoneyAccountLocked: false, + selectedToken: moneyAccountToken, + moneyAccountTotalFiatFormatted: '$12.34', + }); + + render({ params: { flow: 'enable_card' } }); + + expect(screen.getByTestId('account-row')).toBeOnTheScreen(); + expect(screen.queryByTestId('account-row-locked')).not.toBeOnTheScreen(); + expect(screen.getByTestId('account-row-money-account')).toBeOnTheScreen(); + expect(screen.getByTestId('token-row')).toBeOnTheScreen(); + expect(screen.queryByTestId('token-row-locked')).not.toBeOnTheScreen(); + expect(screen.getByText('mUSD ($12.34)')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.testIds.ts b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.testIds.ts index 553cbcf0fb3..6c99d45cd05 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.testIds.ts +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.testIds.ts @@ -1,6 +1,7 @@ export const SpendingLimitSelectors = { LOADING_INDICATOR: 'spending-limit-loading-indicator', ACCOUNT_ROW: 'account-row', + ACCOUNT_ROW_LOCKED: 'account-row-locked', ACCOUNT_ROW_MONEY_ACCOUNT: 'account-row-money-account', TOKEN_ROW: 'token-row', TOKEN_ROW_LOCKED: 'token-row-locked', diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx index 5671f3165a9..7d0420bc2d2 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef } from 'react'; -import { ActivityIndicator, Image, TouchableOpacity } from 'react-native'; +import { ActivityIndicator, TouchableOpacity } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; @@ -21,31 +21,21 @@ import { useTheme } from '../../../../../util/theme'; import { strings } from '../../../../../../locales/i18n'; import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; import { selectAvatarAccountType } from '../../../../../selectors/settings'; -import AvatarAccount from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount'; -import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; -import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../component-library/components/Badges/BadgeWrapper'; -import Badge, { - BadgeVariant, -} from '../../../../../component-library/components/Badges/Badge'; import { useAccountGroupName } from '../../../../hooks/multichainAccounts/useAccountGroupName'; -import { NetworkBadgeSource } from '../../../AssetOverview/Balance/Balance'; import { CardFundingToken } from '../../types'; import useSpendingLimit from '../../hooks/useSpendingLimit'; import { useCardHomeData } from '../../hooks/useCardHomeData'; import useSpendingLimitData from '../../hooks/useSpendingLimitData'; import { buildTokenIconUrl } from '../../util/buildTokenIconUrl'; import { mapCaipChainIdToChainName } from '../../util/mapCaipChainIdToChainName'; -import { safeFormatChainIdToHex } from '../../util/safeFormatChainIdToHex'; import { LINEA_CAIP_CHAIN_ID } from '../../util/buildTokenList'; -import musdAssetIcon from '../../../../../images/musd-icon-2x.png'; +import AccountRow from './components/AccountRow'; +import TokenRow from './components/TokenRow'; import SpendAndEarnPromoCard from './components/SpendAndEarnPromoCard'; import { SpendingLimitSelectors } from './SpendingLimit.testIds'; interface SpendingLimitRouteParams { - flow?: 'manage' | 'enable' | 'onboarding'; + flow?: 'manage' | 'enable' | 'onboarding' | 'enable_card'; selectedToken?: CardFundingToken; } @@ -69,19 +59,14 @@ const SpendingLimit: React.FC = ({ route }) => { const avatarAccountType = useSelector(selectAvatarAccountType); const accountGroupName = useAccountGroupName(); - // Route params carry only intent const flow = route?.params?.flow || 'manage'; const isOnboardingFlow = flow === 'onboarding'; const selectedTokenFromRoute = route?.params?.selectedToken; - - // Read card data from state (not navigation params) const { primaryToken, availableTokens: homeAvailableTokens, data: cardHomeData, } = useCardHomeData(); - - // For onboarding flow when CardHomeData is empty, fetch delegation settings const { availableTokens: hookAvailableTokens, delegationSettings: hookDelegationSettings, @@ -96,7 +81,6 @@ const SpendingLimit: React.FC = ({ route }) => { } }, [isOnboardingFlow, homeAvailableTokens.length, fetchHookData]); - // Determine data sources: prefer CardHomeData, fall back to hook data for onboarding const allTokens = homeAvailableTokens.length > 0 ? homeAvailableTokens @@ -107,7 +91,6 @@ const SpendingLimit: React.FC = ({ route }) => { cardHomeData?.delegationSettings ?? (isOnboardingFlow ? hookDelegationSettings : null); - // Spending limit hook const { selectedToken, limitType, @@ -121,6 +104,7 @@ const SpendingLimit: React.FC = ({ route }) => { skip, isValid, isMoneyAccountSource, + isMoneyAccountLocked, canShowMoneyAccountCta, selectMoneyAccountAsSource, moneyAccountTotalFiatFormatted, @@ -150,7 +134,6 @@ const SpendingLimit: React.FC = ({ route }) => { return unsubscribe; }, [navigation]); - // Derived display values const tokenLabel = useMemo(() => { if (!selectedToken) return ''; const chainId = selectedToken.caipChainId ?? LINEA_CAIP_CHAIN_ID; @@ -184,7 +167,7 @@ const SpendingLimit: React.FC = ({ route }) => { }, [moneyAccountTotalFiatFormatted]); const shouldWaitForMoneyAccountBalance = - flow !== 'enable' && canLinkMoneyAccount; + (flow === 'onboarding' || flow === 'enable_card') && canLinkMoneyAccount; if ( (isOnboardingFlow && isLoadingHookData) || (shouldWaitForMoneyAccountBalance && isMoneyAccountBalanceLoading) @@ -284,150 +267,23 @@ const SpendingLimit: React.FC = ({ route }) => { {/* Settings card */} - {/* Account row */} - - - - {strings('card.card_spending_limit.account_label')} - - {isMoneyAccountSource ? ( - - - - {strings('card.card_spending_limit.money_account_label')} - - - - ) : ( - selectedAccount && ( - - - - {accountGroupName ?? selectedAccount.metadata.name} - - - - ) - )} - - - - {/* Token row */} - {isMoneyAccountSource ? ( - - - {strings('card.card_spending_limit.token_label')} - - - - - {moneyAccountTokenDisplayLabel} - - - - ) : ( - - - - {strings('card.card_spending_limit.token_label')} - - - {selectedToken && tokenIconUrl && ( - - } - > - - - )} - - {tokenLabel} - - - - - - )} + /> + {/* Spending limit row */} void; +} + +const RowLabel = () => ( + + {strings('card.card_spending_limit.account_label')} + +); + +const Chevron = () => { + const tw = useTailwind(); + return ( + + ); +}; + +const MoneyAccountChip = ({ showChevron }: { showChevron: boolean }) => { + const tw = useTailwind(); + return ( + + + + {strings('card.card_spending_limit.money_account_label')} + + {showChevron && } + + ); +}; + +const RegularAccountChip = ({ + selectedAccount, + avatarAccountType, + accountGroupName, +}: Pick< + AccountRowProps, + 'selectedAccount' | 'avatarAccountType' | 'accountGroupName' +>) => { + if (!selectedAccount) return null; + return ( + + + + {accountGroupName ?? selectedAccount.metadata.name} + + + + ); +}; + +const AccountRow: React.FC = ({ + isMoneyAccountLocked, + isMoneyAccountSource, + selectedAccount, + avatarAccountType, + accountGroupName, + onPress, +}) => { + if (isMoneyAccountLocked) { + return ( + + + + + ); + } + return ( + + + + {isMoneyAccountSource ? ( + + ) : ( + + )} + + + ); +}; + +export default AccountRow; diff --git a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx index 202986d6e3f..d866e11e1b9 100644 --- a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx @@ -93,7 +93,7 @@ const SpendAndEarnPromoCard: React.FC = ({ colors={BUTTON_SHIMMER_COLORS} widthFraction={0.7} sweepDurationMs={1200} - pauseDurationMs={900} + pauseDurationMs={6000} testID={`${testID}-shimmer`} >