From a52db0b6efbf4ac344768216d8358de12a49ad66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Wed, 12 Nov 2025 21:04:47 -0300 Subject: [PATCH 1/6] feat(predict): calculate net deposit amount after deducting fees (#22587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Add calculateNetAmount utility function to format.ts - Calculate net amount as totalFiat - bridgeFeeFiat - networkFeeFiat - Add comprehensive test coverage with 17 test cases covering: - Happy path calculations with high precision decimals - Edge cases (zero fees, missing parameters) - Error conditions (invalid inputs, NaN values) - Negative amount protection - Update usePredictDepositToasts to display net amount instead of total - Ensure missing fees are treated as zero (practical approach) - Return "0" for invalid or missing totalFiat ## **Changelog** CHANGELOG entry: null ## **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** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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] > Adds `calculateNetAmount` and updates deposit toast to show net fiat after fees, with comprehensive tests. > > - **Utils**: > - Add `calculateNetAmount` in `app/components/UI/Predict/utils/format.ts` to compute net fiat (`totalFiat - bridgeFeeFiat - networkFeeFiat`) with validation and non-negative guard. > - **UI**: > - Update `usePredictDepositToasts` to display net amount in confirmed toast using `calculateNetAmount` + `formatPrice`. > - **Tests**: > - Expand `format.test.ts` with extensive cases for `calculateNetAmount` (valid, precision, missing/invalid inputs, zero/large values). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b3d56a6150f3ef81294320f71a0b9c5ea69c2d41. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Predict/hooks/usePredictDepositToasts.tsx | 18 +- .../UI/Predict/utils/format.test.ts | 198 ++++++++++++++++++ app/components/UI/Predict/utils/format.ts | 47 +++++ 3 files changed, 258 insertions(+), 5 deletions(-) diff --git a/app/components/UI/Predict/hooks/usePredictDepositToasts.tsx b/app/components/UI/Predict/hooks/usePredictDepositToasts.tsx index 2e638cdd4fd..433b708c8ec 100644 --- a/app/components/UI/Predict/hooks/usePredictDepositToasts.tsx +++ b/app/components/UI/Predict/hooks/usePredictDepositToasts.tsx @@ -7,7 +7,7 @@ import { usePredictBalance } from './usePredictBalance'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; -import { formatPrice } from '../utils/format'; +import { formatPrice, calculateNetAmount } from '../utils/format'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; import { useSelector } from 'react-redux'; import { selectPredictPendingDepositByAddress } from '../selectors/predictController'; @@ -52,10 +52,18 @@ export const usePredictDepositToasts = ({ description: strings('predict.deposit.account_ready_description', { amount: '{amount}', }), - getAmount: (transactionMeta) => - formatPrice(transactionMeta.metamaskPay?.totalFiat ?? 0, { - maximumDecimals: 2, - }) ?? 'Balance', + getAmount: (transactionMeta) => { + const netAmount = calculateNetAmount({ + totalFiat: transactionMeta.metamaskPay?.totalFiat, + bridgeFeeFiat: transactionMeta.metamaskPay?.bridgeFeeFiat, + networkFeeFiat: transactionMeta.metamaskPay?.networkFeeFiat, + }); + return ( + formatPrice(netAmount, { + maximumDecimals: 2, + }) ?? 'Balance' + ); + }, }, errorToastConfig: { title: strings('predict.deposit.error_title'), diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index 2cf4d0f5754..6034489fc05 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -8,6 +8,7 @@ import { getRecurrence, formatCents, formatPositionSize, + calculateNetAmount, } from './format'; import { Recurrence, PredictSeries } from '../types'; @@ -1255,4 +1256,201 @@ describe('format utils', () => { expect(result).toBe('5'); }); }); + + describe('calculateNetAmount', () => { + it('calculates net amount by subtracting fees from total', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0.50', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('9.25'); + }); + + it('calculates net amount with high precision decimal values', () => { + const params = { + totalFiat: '1.04361142938843253220839271649743403', + bridgeFeeFiat: '0.036399', + networkFeeFiat: '0.008024478270232503211154803918368', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0.9991879511181999'); + }); + + it('returns "0" when total equals sum of fees', () => { + const params = { + totalFiat: '1.00', + bridgeFeeFiat: '0.50', + networkFeeFiat: '0.50', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when fees exceed total', () => { + const params = { + totalFiat: '1.00', + bridgeFeeFiat: '0.75', + networkFeeFiat: '0.50', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when totalFiat is undefined', () => { + const params = { + bridgeFeeFiat: '0.50', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('treats missing bridgeFeeFiat as zero', () => { + const params = { + totalFiat: '10.00', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('9.75'); + }); + + it('treats missing networkFeeFiat as zero', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0.50', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('9.5'); + }); + + it('returns full total when both fees are missing', () => { + const params = { + totalFiat: '10.00', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('10'); + }); + + it('returns "0" when all parameters are undefined', () => { + const params = {}; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when totalFiat is invalid string', () => { + const params = { + totalFiat: 'invalid', + bridgeFeeFiat: '0.50', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when bridgeFeeFiat is invalid string', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: 'invalid', + networkFeeFiat: '0.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('returns "0" when networkFeeFiat is invalid string', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0.50', + networkFeeFiat: 'invalid', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0'); + }); + + it('calculates correctly when fees are zero', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0', + networkFeeFiat: '0', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('10'); + }); + + it('calculates correctly when only bridge fee exists', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '2.50', + networkFeeFiat: '0', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('7.5'); + }); + + it('calculates correctly when only network fee exists', () => { + const params = { + totalFiat: '10.00', + bridgeFeeFiat: '0', + networkFeeFiat: '3.25', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('6.75'); + }); + + it('handles very small decimal amounts precisely', () => { + const params = { + totalFiat: '0.001', + bridgeFeeFiat: '0.0001', + networkFeeFiat: '0.0002', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('0.0007'); + }); + + it('handles large amounts correctly', () => { + const params = { + totalFiat: '1000000.00', + bridgeFeeFiat: '50.00', + networkFeeFiat: '25.00', + }; + + const result = calculateNetAmount(params); + + expect(result).toBe('999925'); + }); + }); }); diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index cb69ec56caf..5e033b1758b 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -77,6 +77,53 @@ export const formatPrice = ( }); }; +/** + * Calculates the net amount after deducting bridge and network fees from the total fiat amount + * @param params - Object containing fee and total amount information + * @param params.totalFiat - Total fiat amount as string + * @param params.bridgeFeeFiat - Bridge fee amount as string + * @param params.networkFeeFiat - Network fee amount as string + * @returns Net amount as string after deducting fees, or "0" if calculation fails + * @example + * calculateNetAmount({ + * totalFiat: "1.04361142938843253220839271649743403", + * bridgeFeeFiat: "0.036399", + * networkFeeFiat: "0.008024478270232503211154803918368" + * }) => "0.999187951118199" + */ +export const calculateNetAmount = (params: { + totalFiat?: string; + bridgeFeeFiat?: string; + networkFeeFiat?: string; +}): string => { + const { totalFiat, bridgeFeeFiat, networkFeeFiat } = params; + + // totalFiat is required - return "0" if missing or invalid + if (!totalFiat) { + return '0'; + } + + const total = parseFloat(totalFiat); + if (isNaN(total)) { + return '0'; + } + + // Treat missing fees as 0, but validate they are numbers if provided + const bridgeFee = bridgeFeeFiat ? parseFloat(bridgeFeeFiat) : 0; + const networkFee = networkFeeFiat ? parseFloat(networkFeeFiat) : 0; + + // Return "0" if any provided fee is invalid + if (isNaN(bridgeFee) || isNaN(networkFee)) { + return '0'; + } + + // Calculate net amount: totalFiat - bridgeFee - networkFee + const netAmount = total - bridgeFee - networkFee; + + // Ensure we don't return negative amounts + return netAmount > 0 ? netAmount.toString() : '0'; +}; + /** * Formats a volume value with appropriate suffix based on magnitude * @param volume - Raw numeric volume value From e0358f67493bd12eb77f0e88858d947eed1c62f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Wed, 12 Nov 2025 17:33:49 -0700 Subject: [PATCH 2/6] fix(predict): optimistic updates showing in other markets (#22584) ## **Description** ### What is the reason for the change? There was a bug in the Polymarket provider's optimistic position update system where: 1. When viewing a specific market, optimistic positions from ALL other markets would incorrectly appear in the UI ### What is the improvement/solution? Fixed `applyOptimisticPositionUpdates()` to properly handle query filters: - Added `marketId` and `outcomeId` parameters to respect the position query context - Restructured the update processing logic to always check ALL updates for cleanup (timeout and confirmation), while only applying updates to results when they match the current query filters The key insight is that cleanup logic must run globally (checking all updates regardless of filters), while the application logic should be scoped (only applying matching updates to results). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [Issue number if applicable] ## **Manual testing steps** ```gherkin Feature: Predict optimistic position updates with query filters Scenario: user views a specific market after placing orders on multiple markets Given user has placed buy orders on Market A and Market B And optimistic positions exist for both markets When user opens Market A detail view Then user should only see optimistic positions for Market A And user should not see optimistic positions for Market B Scenario: user sells/claims a position and optimistic update is cleaned up Given user has an open position on Market A And user navigates to Market A detail view When user sells the entire position Then user should immediately see the position removed (optimistic removal) When user navigates to all positions view And the API confirms the position is removed Then the optimistic update should be cleaned up from memory Scenario: user has optimistic updates that get cleaned up across different views Given user has placed an order on Market A And an optimistic position exists for Market A When user navigates to Market B detail view (filtered query) And the API for Market A confirms the position update And user navigates back to all positions view Then the optimistic update should be properly cleaned up ``` ## **Screenshots/Recordings** ### **Before** - When viewing a specific market, optimistic positions from all other markets would appear - Optimistic REMOVE updates would never get cleaned up when viewing filtered queries - Stale optimistic updates would persist until timeout (1 minute) ### **After** - Only optimistic positions relevant to the current view are shown - Optimistic REMOVE updates are properly cleaned up when API confirms deletion - Updates are checked for cleanup regardless of current query filters ## **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. --- > [!NOTE] > Scopes optimistic position updates by `marketId`/`outcomeId` to prevent positions from appearing in unrelated markets while retaining global cleanup. > > - **PolymarketProvider** (`app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts`): > - Add `marketId` and `outcomeId` parameters to `applyOptimisticPositionUpdates()` and propagate from `getPositions()`. > - Apply optimistic updates only when they match the current query filters (`marketId`/`outcomeId`), preventing cross-market leakage. > - Keep global cleanup logic (timeout and API-confirmation) while filtering application of optimistic positions. > - Guard against applying optimistic positions when `claimable` is true remains enforced during filtered updates. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cd209212dcee4b846da08113b61421dc22354b1d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../polymarket/PolymarketProvider.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index f3a6bba63cb..7f144616ec4 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -610,10 +610,14 @@ export class PolymarketProvider implements PredictProvider { address, positions, claimable, + marketId, + outcomeId, }: { address: string; positions: PredictPosition[]; claimable: boolean; + marketId?: string; + outcomeId?: string; }): PredictPosition[] { const optimisticUpdates = this.#optimisticPositionUpdatesByAddress.get(address); @@ -642,6 +646,11 @@ export class PolymarketProvider implements PredictProvider { return; } + // Check if this update matches the query filters + const matchesFilter = + (!outcomeId || update.outcomeTokenId === outcomeId) && + (!marketId || outcomeId || update.marketId === marketId); + const apiPositionIndex = result.findIndex( (p) => p.outcomeTokenId === outcomeTokenId, ); @@ -664,12 +673,16 @@ export class PolymarketProvider implements PredictProvider { expectedSize: update.expectedSize, }, ); - } else if (update.optimisticPosition) { - // API not yet updated, use optimistic position + } else if ( + update.optimisticPosition && + !claimable && + matchesFilter + ) { + // API not yet updated, use optimistic position (only if matches filter) result[apiPositionIndex] = update.optimisticPosition; } - } else if (update.optimisticPosition && !claimable) { - // New position not in API yet, add optimistic position + } else if (update.optimisticPosition && !claimable && matchesFilter) { + // New position not in API yet, add optimistic position (only if matches filter) result.push(update.optimisticPosition); } break; @@ -760,6 +773,8 @@ export class PolymarketProvider implements PredictProvider { address, positions: parsedPositions, claimable, + marketId, + outcomeId, }); return positionsWithOptimisticUpdates; From 7a527d319ac3f95ce77fd65f21d5654f85062669 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 12 Nov 2025 18:00:15 -0700 Subject: [PATCH 3/6] feat(ramps): adds ramps eligibility failed modal (#22343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We need a modal to display when a user's eligibility for ramps cannot be determined based on their region. This is part of the larger ramps eligibility checking feature. **What is the improvement/solution?** Created a new `EligibilityFailedModal` component that displays when eligibility checks fail. The modal: - Is registered at the root level (not nested in Ramp flow) so it can be shown before users enter any ramp flow - Displays a clear error message: "We couldn't confirm access based on your region. Please try again. If the issue continues, contact support." - Includes a "Got It" button that closes the modal and navigates back **Note:** This PR sets up the infrastructure and routing. The actual eligibility checking logic will be integrated in a separate PR. ## **Changelog** CHANGELOG entry: null _(Internal infrastructure change - not user-facing until eligibility logic is integrated)_ ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2833 ## **Manual testing steps** ```gherkin Feature: Ramps Eligibility Failed Modal Scenario: Modal displays with correct content Given the EligibilityFailedModal is triggered via navigation When the modal opens Then user sees the title "Eligibility Check Failed" And user sees the description "We couldn't confirm access based on your region. Please try again. If the issue continues, contact support." And user sees a "Got It" button Scenario: User dismisses the modal Given the EligibilityFailedModal is open When user taps the "Got It" button Then the modal closes And user navigates back to the previous screen Scenario: User closes modal with close button Given the EligibilityFailedModal is open When user taps the X close button in the header Then the modal closes And user navigates back to the previous screen ``` ## **Files Changed** ### New Files - `app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx` - `app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.styles.ts` - `app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.test.tsx` - `app/components/UI/Ramp/components/EligibilityFailedModal/index.ts` ### Modified Files - `app/constants/navigation/Routes.ts` - Added `ELIGIBILITY_FAILED_MODAL` to `Routes.SHEET` - `app/components/Nav/App/App.tsx` - Registered modal in `RootModalFlow` - `locales/languages/en.json` - Added translations under `fiat_on_ramp_aggregator.eligibility_failed_modal` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/84ed7a02-eb22-47a9-bd8d-e2c1be1f9e39 ## **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 - [ ] 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. --- > [!NOTE] > Adds an `EligibilityFailedModal` bottom sheet for ramps, registers its route in `RootModalFlow`, and includes tests and translations. > > - **UI (Ramps)**: > - Add `EligibilityFailedModal` component (`app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx`) with styles and actions (open support link, close sheet). > - **Navigation**: > - Register `Routes.SHEET.ELIGIBILITY_FAILED_MODAL` in `App.tsx` under `RootModalFlow` and define route in `app/constants/navigation/Routes.ts`. > - **Tests**: > - Add component tests with snapshot and behavior (`EligibilityFailedModal.test.tsx`). > - Extend `App.test.tsx` to verify route registration and rendering via initial nav state. > - **i18n**: > - Add strings under `fiat_on_ramp_aggregator.eligibility_failed_modal` in `locales/languages/en.json`. > - **Exports**: > - Add index export for `EligibilityFailedModal`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 029d7726b357456945b635301715335111b3cdf0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/App/App.test.tsx | 41 ++ app/components/Nav/App/App.tsx | 5 + .../EligibilityFailedModal.styles.ts | 16 + .../EligibilityFailedModal.test.tsx | 87 +++ .../EligibilityFailedModal.tsx | 91 +++ .../EligibilityFailedModal.test.tsx.snap | 526 ++++++++++++++++++ .../EligibilityFailedModal/index.ts | 2 + app/constants/navigation/Routes.ts | 1 + locales/languages/en.json | 6 + 9 files changed, 775 insertions(+) create mode 100644 app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.styles.ts create mode 100644 app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.test.tsx create mode 100644 app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx create mode 100644 app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap create mode 100644 app/components/UI/Ramp/components/EligibilityFailedModal/index.ts diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index ea5a3bce5d1..0ca83a2465b 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -890,4 +890,45 @@ describe('App', () => { }); }); }); + + describe('route registration', () => { + const renderAppWithRouteState = ( + routeState: PartialState, + ) => { + const mockStore = configureMockStore(); + const store = mockStore(initialState); + + const Providers = ({ children }: { children: React.ReactElement }) => ( + + + + {children} + + + + ); + + return render(, { wrapper: Providers }); + }; + + it('registers the eligibility failed modal route', async () => { + const routeState = { + index: 0, + routes: [ + { + name: Routes.MODAL.ROOT_MODAL_FLOW, + params: { + screen: Routes.SHEET.ELIGIBILITY_FAILED_MODAL, + }, + }, + ], + }; + + const { getByTestId } = renderAppWithRouteState(routeState); + + await waitFor(() => { + expect(getByTestId('eligibility-failed-modal')).toBeTruthy(); + }); + }); + }); }); diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 106be80d5e3..c28a100cb10 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -128,6 +128,7 @@ import SkipAccountSecurityModal from '../../UI/SkipAccountSecurityModal'; import SuccessErrorSheet from '../../Views/SuccessErrorSheet'; import ConfirmTurnOnBackupAndSyncModal from '../../UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal'; import AddNewAccountBottomSheet from '../../Views/AddNewAccount/AddNewAccountBottomSheet'; +import EligibilityFailedModal from '../../UI/Ramp/components/EligibilityFailedModal'; import SwitchAccountTypeModal from '../../Views/confirmations/components/modals/switch-account-type-modal'; import { AccountDetails } from '../../Views/MultichainAccounts/AccountDetails/AccountDetails'; import { AccountGroupDetails } from '../../Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails'; @@ -400,6 +401,10 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.SHEET.SUCCESS_ERROR_SHEET} component={SuccessErrorSheet} /> + + StyleSheet.create({ + content: { + paddingHorizontal: 24, + paddingBottom: 24, + }, + footer: { + gap: 16, + paddingHorizontal: 24, + paddingBottom: 24, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.test.tsx b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.test.tsx new file mode 100644 index 00000000000..dcaa98af485 --- /dev/null +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import EligibilityFailedModal from './EligibilityFailedModal'; +import Routes from '../../../../../constants/navigation/Routes'; +import initialRootState from '../../../../../util/test/initial-root-state'; +import { fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; + +const mockOnCloseBottomSheet = jest.fn(); + +jest.mock('react-native/Libraries/Linking/Linking', () => ({ + openURL: jest.fn(), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), + canOpenURL: jest.fn().mockResolvedValue(true), +})); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + return ReactActual.forwardRef( + ( + { + children, + }: { + children: React.ReactNode; + }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return <>{children}; + }, + ); + }, +); + +function render(component: React.ComponentType) { + return renderScreen( + component, + { + name: Routes.SHEET.ELIGIBILITY_FAILED_MODAL, + }, + { + state: initialRootState, + }, + ); +} + +describe('EligibilityFailedModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the modal with the correct title and description', () => { + const { toJSON } = render(EligibilityFailedModal); + expect(toJSON()).toMatchSnapshot(); + }); + it('navigates to contact support when the contact support button is pressed', () => { + const { getByText } = render(EligibilityFailedModal); + const contactSupportButton = getByText('Contact Support'); + + fireEvent.press(contactSupportButton); + + expect(Linking.openURL).toHaveBeenCalledWith('https://support.metamask.io'); + }); + + it('closes the modal when the close button is pressed', () => { + const { getByTestId } = render(EligibilityFailedModal); + const closeButton = getByTestId('bottomsheetheader-close-button'); + + fireEvent.press(closeButton); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('closes the modal when the got it button is pressed', () => { + const { getByText } = render(EligibilityFailedModal); + const gotItButton = getByText('Got It'); + + fireEvent.press(gotItButton); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx new file mode 100644 index 00000000000..b2d47bcf507 --- /dev/null +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useRef } from 'react'; +import { View, Linking } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import Button, { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../component-library/components/Buttons/Button'; + +import styleSheet from './EligibilityFailedModal.styles'; +import { useStyles } from '../../../../hooks/useStyles'; +import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; + +const SUPPORT_URL = 'https://support.metamask.io'; + +export const createEligibilityFailedModalNavigationDetails = + createNavigationDetails( + Routes.MODAL.ROOT_MODAL_FLOW, + Routes.SHEET.ELIGIBILITY_FAILED_MODAL, + ); + +function EligibilityFailedModal() { + const sheetRef = useRef(null); + const { styles } = useStyles(styleSheet, {}); + + const navigateToContactSupport = useCallback(() => { + Linking.openURL(SUPPORT_URL); + }, []); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + + {strings('fiat_on_ramp_aggregator.eligibility_failed_modal.title')} + + + + + + {strings( + 'fiat_on_ramp_aggregator.eligibility_failed_modal.description', + )} + + + + +