From 8ceba1c26efdfb52b44082f511e754164fbbf00b Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Fri, 8 May 2026 10:11:31 -0500 Subject: [PATCH] feat(ramp): add navigateToRampBuy helper and refactor useRampNavigation Co-authored-by: Cursor --- .../UI/Ramp/hooks/useRampNavigation.ts | 137 +---- app/components/UI/Ramp/navigation.ts | 13 + .../UI/Ramp/utils/navigateToRampBuy.test.ts | 478 ++++++++++++++++++ .../UI/Ramp/utils/navigateToRampBuy.ts | 162 ++++++ 4 files changed, 672 insertions(+), 118 deletions(-) create mode 100644 app/components/UI/Ramp/navigation.ts create mode 100644 app/components/UI/Ramp/utils/navigateToRampBuy.test.ts create mode 100644 app/components/UI/Ramp/utils/navigateToRampBuy.ts diff --git a/app/components/UI/Ramp/hooks/useRampNavigation.ts b/app/components/UI/Ramp/hooks/useRampNavigation.ts index e8386e716d7..87d92adb0c5 100644 --- a/app/components/UI/Ramp/hooks/useRampNavigation.ts +++ b/app/components/UI/Ramp/hooks/useRampNavigation.ts @@ -7,24 +7,15 @@ import { } from '../Aggregator/types'; import { createRampNavigationDetails } from '../Aggregator/routes/utils'; import { createDepositNavigationDetails } from '../Deposit/routes/utils'; -import { createTokenSelectionNavDetails } from '../Views/TokenSelection/TokenSelection'; -import { createBuildQuoteNavDetails } from '../Views/BuildQuote'; -import type { BuyFlowOrigin } from '../Views/BuildQuote/BuildQuote'; -import useRampsUnifiedV1Enabled from './useRampsUnifiedV1Enabled'; -import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; import { - getRampRoutingDecision, - UnifiedRampRoutingType, -} from '../../../../reducers/fiatOrders'; -import { createRampUnsupportedModalNavigationDetails } from '../components/RampUnsupportedModal/RampUnsupportedModal'; -import { createEligibilityFailedModalNavigationDetails } from '../components/EligibilityFailedModal/EligibilityFailedModal'; + navigateToRampBuy, + NavigateToRampBuyMode, + type NavigateToRampBuyOptions, +} from '../utils/navigateToRampBuy'; +import { getRampRoutingDecision } from '../../../../reducers/fiatOrders'; import { useRampsTokens } from './useRampsTokens'; -import { resolveRampControllerAssetId } from '../utils/resolveRampControllerAssetId'; - -enum RampMode { - AGGREGATOR = 'AGGREGATOR', - DEPOSIT = 'DEPOSIT', -} +import useRampsUnifiedV1Enabled from './useRampsUnifiedV1Enabled'; +import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; /** * Hook that returns functions to navigate to ramp flows. @@ -43,106 +34,14 @@ export const useRampNavigation = () => { const { setSelectedToken, tokens: rampsTokens } = useRampsTokens(); const goToBuy = useCallback( - ( - intent?: RampIntent, - options?: { - mode?: RampMode; - overrideUnifiedRouting?: boolean; - buyFlowOrigin?: BuyFlowOrigin; - }, - ) => { - const { mode = RampMode.AGGREGATOR, overrideUnifiedRouting = false } = - options || {}; - - const isUnifiedRoutingEnabled = - (isRampsUnifiedV1Enabled || isRampsUnifiedV2Enabled) && - !overrideUnifiedRouting; - - // Check error states first (applies to both V1 and V2) - if (isUnifiedRoutingEnabled) { - if (rampRoutingDecision === UnifiedRampRoutingType.ERROR) { - navigation.navigate( - ...createEligibilityFailedModalNavigationDetails(), - ); - return; - } - - if (rampRoutingDecision === UnifiedRampRoutingType.UNSUPPORTED) { - navigation.navigate(...createRampUnsupportedModalNavigationDetails()); - return; - } - } - - // V2: If assetId is provided and V2 is enabled, route to BuildQuote - // TODO: Check for provider support for the token and pass params to BuildQuote to show an error modal - if ( - isRampsUnifiedV2Enabled && - intent?.assetId && - !overrideUnifiedRouting - ) { - // Resolve to the controller's canonical assetId format (lowercase) - const controllerAssetId = resolveRampControllerAssetId( - intent.assetId, - rampsTokens?.allTokens ?? [], - ); - try { - setSelectedToken(controllerAssetId); - } catch { - // Token may not be in controller's list yet (still loading). - // Navigate anyway — BuildQuote will handle the missing token. - } - navigation.navigate( - ...createBuildQuoteNavDetails({ - assetId: controllerAssetId, - buyFlowOrigin: options?.buyFlowOrigin, - }), - ); - return; - } - - // V2: If no assetId and V2 is enabled, route to TokenSelection (matches handleRampUrl deeplink behavior) - if ( - isRampsUnifiedV2Enabled && - !intent?.assetId && - !overrideUnifiedRouting - ) { - navigation.navigate(...createTokenSelectionNavDetails()); - return; - } - - // V1 routing logic - if (isRampsUnifiedV1Enabled && !overrideUnifiedRouting) { - // If no assetId is provided, route to TokenSelection - if (!intent?.assetId) { - navigation.navigate(...createTokenSelectionNavDetails()); - return; - } - - // If routing decision hasn't been determined yet, route to TokenSelection - if (rampRoutingDecision === null) { - navigation.navigate(...createTokenSelectionNavDetails()); - return; - } - - // If assetId is provided, route based on rampRoutingDecision - if (rampRoutingDecision === UnifiedRampRoutingType.DEPOSIT) { - navigation.navigate(...createDepositNavigationDetails(intent)); - } else if (rampRoutingDecision === UnifiedRampRoutingType.AGGREGATOR) { - navigation.navigate( - ...createRampNavigationDetails(AggregatorRampType.BUY, intent), - ); - } - return; - } - - // When overriding unified routing or when v1 is disabled - if (mode === RampMode.DEPOSIT) { - navigation.navigate(...createDepositNavigationDetails(intent)); - } else { - navigation.navigate( - ...createRampNavigationDetails(AggregatorRampType.BUY, intent), - ); - } + (intent?: RampIntent, options?: NavigateToRampBuyOptions) => { + navigateToRampBuy(navigation, intent, options, { + isRampsUnifiedV1Enabled, + isRampsUnifiedV2Enabled, + rampRoutingDecision, + rampsTokensAll: rampsTokens?.allTokens ?? [], + setSelectedToken, + }); }, [ setSelectedToken, @@ -161,7 +60,7 @@ export const useRampNavigation = () => { const goToAggregator = useCallback( (intent?: RampIntent) => { goToBuy(intent, { - mode: RampMode.AGGREGATOR, + mode: NavigateToRampBuyMode.AGGREGATOR, overrideUnifiedRouting: true, }); }, @@ -184,12 +83,14 @@ export const useRampNavigation = () => { const goToDeposit = useCallback( (intent?: RampIntent) => { goToBuy(intent, { - mode: RampMode.DEPOSIT, + mode: NavigateToRampBuyMode.DEPOSIT, overrideUnifiedRouting: true, }); }, [goToBuy], ); + // Deprecated entries remain part of the public hook API for existing callers. + // eslint-disable-next-line @typescript-eslint/no-deprecated -- backward-compatible exports return { goToBuy, goToAggregator, goToSell, goToDeposit }; }; diff --git a/app/components/UI/Ramp/navigation.ts b/app/components/UI/Ramp/navigation.ts new file mode 100644 index 00000000000..a26ea79db16 --- /dev/null +++ b/app/components/UI/Ramp/navigation.ts @@ -0,0 +1,13 @@ +/** + * Stable, shallow imports for cross-team ramp **buy** navigation. + * Prefer importing from this module rather than deep Aggregator/Deposit paths. + */ +export { navigateToRampBuy } from './utils/navigateToRampBuy'; +export type { + NavigateToRampBuyNavigation, + NavigateToRampBuyDeps, + NavigateToRampBuyOptions, +} from './utils/navigateToRampBuy'; +export { NavigateToRampBuyMode } from './utils/navigateToRampBuy'; +export type { RampIntent } from './types'; +export type { BuyFlowOrigin } from './Views/BuildQuote/BuildQuote'; diff --git a/app/components/UI/Ramp/utils/navigateToRampBuy.test.ts b/app/components/UI/Ramp/utils/navigateToRampBuy.test.ts new file mode 100644 index 00000000000..a37b053fcab --- /dev/null +++ b/app/components/UI/Ramp/utils/navigateToRampBuy.test.ts @@ -0,0 +1,478 @@ +import Routes from '../../../../constants/navigation/Routes'; +import { navigateToRampBuy, NavigateToRampBuyMode } from './navigateToRampBuy'; +import { createRampNavigationDetails } from '../Aggregator/routes/utils'; +import { createDepositNavigationDetails } from '../Deposit/routes/utils'; +import { createTokenSelectionNavDetails } from '../Views/TokenSelection/TokenSelection'; +import { createBuildQuoteNavDetails } from '../Views/BuildQuote'; +import { RampType as AggregatorRampType } from '../Aggregator/types'; +import { UnifiedRampRoutingType } from '../../../../reducers/fiatOrders'; +import { createEligibilityFailedModalNavigationDetails } from '../components/EligibilityFailedModal/EligibilityFailedModal'; +import { createRampUnsupportedModalNavigationDetails } from '../components/RampUnsupportedModal/RampUnsupportedModal'; + +jest.mock('../Aggregator/routes/utils'); +jest.mock('../Deposit/routes/utils'); +jest.mock('../Views/TokenSelection/TokenSelection', () => { + const actual = jest.requireActual('../Views/TokenSelection/TokenSelection'); + const mockFn = jest.fn(); + return { + ...actual, + createTokenSelectionNavDetails: mockFn, + createTokenSelectionNavigationDetails: mockFn, + }; +}); +jest.mock('../Views/BuildQuote', () => { + const mockFn = jest.fn(); + return { + createBuildQuoteNavDetails: mockFn, + }; +}); + +const mockNavigate = jest.fn(); +const mockSetSelectedToken = jest.fn(); + +const mockCreateRampNavigationDetails = + createRampNavigationDetails as jest.MockedFunction< + typeof createRampNavigationDetails + >; +const mockCreateDepositNavigationDetails = + createDepositNavigationDetails as jest.MockedFunction< + typeof createDepositNavigationDetails + >; +const mockCreateTokenSelectionNavigationDetails = + createTokenSelectionNavDetails as jest.MockedFunction< + typeof createTokenSelectionNavDetails + >; +const mockCreateBuildQuoteNavDetails = + createBuildQuoteNavDetails as jest.MockedFunction< + typeof createBuildQuoteNavDetails + >; + +function navigate( + intent: Parameters[1], + options: Parameters[2], + deps: Partial[3]> & { + isRampsUnifiedV1Enabled: boolean; + isRampsUnifiedV2Enabled: boolean; + rampRoutingDecision: Parameters< + typeof navigateToRampBuy + >[3]['rampRoutingDecision']; + }, +) { + navigateToRampBuy({ navigate: mockNavigate }, intent, options, { + rampsTokensAll: [], + setSelectedToken: mockSetSelectedToken, + ...deps, + }); +} + +describe('navigateToRampBuy', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSetSelectedToken.mockClear(); + + mockCreateRampNavigationDetails.mockReturnValue([ + Routes.RAMP.BUY, + ] as unknown as ReturnType); + + mockCreateDepositNavigationDetails.mockReturnValue([ + Routes.DEPOSIT.ID, + ] as unknown as ReturnType); + + mockCreateTokenSelectionNavigationDetails.mockReturnValue([ + Routes.RAMP.TOKEN_SELECTION, + ] as unknown as ReturnType); + + mockCreateBuildQuoteNavDetails.mockReturnValue([ + Routes.RAMP.TOKEN_SELECTION, + { + screen: Routes.RAMP.TOKEN_SELECTION, + params: { + screen: Routes.RAMP.AMOUNT_INPUT, + params: { assetId: 'eip155:1/erc20:0x123' }, + }, + }, + ] as unknown as ReturnType); + }); + + describe('when unified V2 is enabled', () => { + const v2Deps = { + isRampsUnifiedV1Enabled: false, + isRampsUnifiedV2Enabled: true, + rampRoutingDecision: null, + }; + + it('navigates to BuildQuote when assetId is provided', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + const mockNavDetails = [ + Routes.RAMP.TOKEN_SELECTION, + { + screen: Routes.RAMP.TOKEN_SELECTION, + params: { + screen: Routes.RAMP.AMOUNT_INPUT, + params: { assetId: intent.assetId }, + }, + }, + ] as const; + mockCreateBuildQuoteNavDetails.mockReturnValue(mockNavDetails); + + navigate(intent, undefined, v2Deps); + + expect(mockSetSelectedToken).toHaveBeenCalledWith(intent.assetId); + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ + assetId: intent.assetId, + buyFlowOrigin: undefined, + }); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + }); + + it('passes buyFlowOrigin through to BuildQuote params', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + + navigate(intent, { buyFlowOrigin: 'tokenInfo' }, v2Deps); + + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ + assetId: intent.assetId, + buyFlowOrigin: 'tokenInfo', + }); + }); + + it('passes homeTokenList buyFlowOrigin through to BuildQuote params', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + + navigate(intent, { buyFlowOrigin: 'homeTokenList' }, v2Deps); + + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ + assetId: intent.assetId, + buyFlowOrigin: 'homeTokenList', + }); + }); + + it('navigates to TokenSelection when no assetId and V1 is disabled', () => { + const mockNavDetails = [Routes.RAMP.TOKEN_SELECTION, undefined] as const; + mockCreateTokenSelectionNavigationDetails.mockReturnValue(mockNavDetails); + + navigate(undefined, undefined, v2Deps); + + expect(mockSetSelectedToken).not.toHaveBeenCalled(); + expect(mockCreateBuildQuoteNavDetails).not.toHaveBeenCalled(); + expect(mockCreateTokenSelectionNavigationDetails).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + }); + + it('navigates to TokenSelection when no assetId and V1 is also enabled', () => { + const mockNavDetails = [Routes.RAMP.TOKEN_SELECTION, undefined] as const; + mockCreateTokenSelectionNavigationDetails.mockReturnValue(mockNavDetails); + + navigate(undefined, undefined, { + ...v2Deps, + isRampsUnifiedV1Enabled: true, + }); + + expect(mockSetSelectedToken).not.toHaveBeenCalled(); + expect(mockCreateBuildQuoteNavDetails).not.toHaveBeenCalled(); + expect(mockCreateTokenSelectionNavigationDetails).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + }); + + it('does not navigate to BuildQuote when overrideUnifiedRouting is true', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + const mockNavDetails = [Routes.RAMP.BUY] as const; + mockCreateRampNavigationDetails.mockReturnValue(mockNavDetails); + + navigate(intent, { overrideUnifiedRouting: true }, v2Deps); + + expect(mockSetSelectedToken).not.toHaveBeenCalled(); + expect(mockCreateBuildQuoteNavDetails).not.toHaveBeenCalled(); + expect(mockCreateRampNavigationDetails).toHaveBeenCalledWith( + AggregatorRampType.BUY, + intent, + ); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + }); + + it('takes precedence over V1 routing when V2 is enabled with assetId', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + const mockNavDetails = [ + Routes.RAMP.TOKEN_SELECTION, + { + screen: Routes.RAMP.TOKEN_SELECTION, + params: { + screen: Routes.RAMP.AMOUNT_INPUT, + params: { assetId: intent.assetId }, + }, + }, + ] as const; + mockCreateBuildQuoteNavDetails.mockReturnValue(mockNavDetails); + + navigate(intent, undefined, { + isRampsUnifiedV1Enabled: true, + isRampsUnifiedV2Enabled: true, + rampRoutingDecision: UnifiedRampRoutingType.DEPOSIT, + }); + + expect(mockSetSelectedToken).toHaveBeenCalledWith(intent.assetId); + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ + assetId: intent.assetId, + buyFlowOrigin: undefined, + }); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + }); + + describe('error and unsupported routing takes precedence over V2', () => { + it('navigates to eligibility failed modal when routing decision is ERROR', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + const navDetails = createEligibilityFailedModalNavigationDetails(); + + navigate(intent, undefined, { + ...v2Deps, + rampRoutingDecision: UnifiedRampRoutingType.ERROR, + }); + + expect(mockSetSelectedToken).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(...navDetails); + expect(mockCreateBuildQuoteNavDetails).not.toHaveBeenCalled(); + }); + + it('navigates to unsupported modal when routing decision is UNSUPPORTED', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + const navDetails = createRampUnsupportedModalNavigationDetails(); + + navigate(intent, undefined, { + ...v2Deps, + rampRoutingDecision: UnifiedRampRoutingType.UNSUPPORTED, + }); + + expect(mockSetSelectedToken).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(...navDetails); + expect(mockCreateBuildQuoteNavDetails).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when unified V1 is disabled', () => { + const legacyDeps = { + isRampsUnifiedV1Enabled: false, + isRampsUnifiedV2Enabled: false, + rampRoutingDecision: null, + }; + + it('navigates to aggregator BUY without intent', () => { + const mockNavDetails = [Routes.RAMP.BUY] as const; + mockCreateRampNavigationDetails.mockReturnValue(mockNavDetails); + + navigate(undefined, undefined, legacyDeps); + + expect(mockCreateRampNavigationDetails).toHaveBeenCalledWith( + AggregatorRampType.BUY, + undefined, + ); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + }); + + it('navigates to aggregator with intent', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + const mockNavDetails = [Routes.RAMP.BUY] as const; + mockCreateRampNavigationDetails.mockReturnValue(mockNavDetails); + + navigate(intent, undefined, legacyDeps); + + expect(mockCreateRampNavigationDetails).toHaveBeenCalledWith( + AggregatorRampType.BUY, + intent, + ); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + }); + }); + + describe('when unified V1 is enabled', () => { + const v1Deps = { + isRampsUnifiedV1Enabled: true, + isRampsUnifiedV2Enabled: false, + rampRoutingDecision: null as UnifiedRampRoutingType | null, + }; + + describe('error and unsupported routing', () => { + it('navigates to eligibility failed modal when routing decision is ERROR', () => { + const navDetails = createEligibilityFailedModalNavigationDetails(); + + navigate(undefined, undefined, { + ...v1Deps, + rampRoutingDecision: UnifiedRampRoutingType.ERROR, + }); + + expect(mockNavigate).toHaveBeenCalledWith(...navDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + expect( + mockCreateTokenSelectionNavigationDetails, + ).not.toHaveBeenCalled(); + }); + + it('navigates to eligibility failed modal when routing decision is ERROR with intent', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + const navDetails = createEligibilityFailedModalNavigationDetails(); + + navigate(intent, undefined, { + ...v1Deps, + rampRoutingDecision: UnifiedRampRoutingType.ERROR, + }); + + expect(mockNavigate).toHaveBeenCalledWith(...navDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + expect( + mockCreateTokenSelectionNavigationDetails, + ).not.toHaveBeenCalled(); + }); + + it('navigates to unsupported modal when routing decision is UNSUPPORTED', () => { + const navDetails = createRampUnsupportedModalNavigationDetails(); + + navigate(undefined, undefined, { + ...v1Deps, + rampRoutingDecision: UnifiedRampRoutingType.UNSUPPORTED, + }); + + expect(mockNavigate).toHaveBeenCalledWith(...navDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + expect( + mockCreateTokenSelectionNavigationDetails, + ).not.toHaveBeenCalled(); + }); + + it('navigates to unsupported modal when routing decision is UNSUPPORTED with intent', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + const navDetails = createRampUnsupportedModalNavigationDetails(); + + navigate(intent, undefined, { + ...v1Deps, + rampRoutingDecision: UnifiedRampRoutingType.UNSUPPORTED, + }); + + expect(mockNavigate).toHaveBeenCalledWith(...navDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + expect( + mockCreateTokenSelectionNavigationDetails, + ).not.toHaveBeenCalled(); + }); + }); + + describe('token selection routing', () => { + it('navigates to TokenSelection when no assetId is provided', () => { + const mockNavDetails = [ + Routes.RAMP.TOKEN_SELECTION, + undefined, + ] as const; + mockCreateTokenSelectionNavigationDetails.mockReturnValue( + mockNavDetails, + ); + + navigate(undefined, undefined, v1Deps); + + expect(mockCreateTokenSelectionNavigationDetails).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + }); + + it('navigates to TokenSelection when intent is provided without assetId', () => { + const intent = { amount: '100', currency: 'USD' }; + const mockNavDetails = [ + Routes.RAMP.TOKEN_SELECTION, + undefined, + ] as const; + mockCreateTokenSelectionNavigationDetails.mockReturnValue( + mockNavDetails, + ); + + navigate(intent, undefined, v1Deps); + + expect(mockCreateTokenSelectionNavigationDetails).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + }); + }); + + describe('smart routing based on routing decision', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + + it('navigates to TokenSelection when routing decision is null', () => { + const mockNavDetails = [ + Routes.RAMP.TOKEN_SELECTION, + undefined, + ] as const; + mockCreateTokenSelectionNavigationDetails.mockReturnValue( + mockNavDetails, + ); + + navigate(intent, undefined, { ...v1Deps, rampRoutingDecision: null }); + + expect(mockCreateTokenSelectionNavigationDetails).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + }); + + it('navigates to deposit when routing decision is DEPOSIT', () => { + const mockNavDetails = [Routes.DEPOSIT.ID] as const; + mockCreateDepositNavigationDetails.mockReturnValue(mockNavDetails); + + navigate(intent, undefined, { + ...v1Deps, + rampRoutingDecision: UnifiedRampRoutingType.DEPOSIT, + }); + + expect(mockCreateDepositNavigationDetails).toHaveBeenCalledWith(intent); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); + }); + + it('navigates to aggregator when routing decision is AGGREGATOR', () => { + const mockNavDetails = [Routes.RAMP.BUY] as const; + mockCreateRampNavigationDetails.mockReturnValue(mockNavDetails); + + navigate(intent, undefined, { + ...v1Deps, + rampRoutingDecision: UnifiedRampRoutingType.AGGREGATOR, + }); + + expect(mockCreateRampNavigationDetails).toHaveBeenCalledWith( + AggregatorRampType.BUY, + intent, + ); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); + }); + }); + }); + + describe('override mode', () => { + it('navigates to deposit when mode is DEPOSIT and override unified routing', () => { + const mockNavDetails = [Routes.DEPOSIT.ID] as const; + mockCreateDepositNavigationDetails.mockReturnValue(mockNavDetails); + + navigate( + undefined, + { + mode: NavigateToRampBuyMode.DEPOSIT, + overrideUnifiedRouting: true, + }, + { + isRampsUnifiedV1Enabled: true, + isRampsUnifiedV2Enabled: false, + rampRoutingDecision: UnifiedRampRoutingType.AGGREGATOR, + }, + ); + + expect(mockCreateDepositNavigationDetails).toHaveBeenCalledWith( + undefined, + ); + expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); + }); + }); +}); diff --git a/app/components/UI/Ramp/utils/navigateToRampBuy.ts b/app/components/UI/Ramp/utils/navigateToRampBuy.ts new file mode 100644 index 00000000000..053394255f8 --- /dev/null +++ b/app/components/UI/Ramp/utils/navigateToRampBuy.ts @@ -0,0 +1,162 @@ +import type { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { + RampIntent, + RampType as AggregatorRampType, +} from '../Aggregator/types'; +import { createRampNavigationDetails } from '../Aggregator/routes/utils'; +import { createDepositNavigationDetails } from '../Deposit/routes/utils'; +import { createTokenSelectionNavDetails } from '../Views/TokenSelection/TokenSelection'; +import { createBuildQuoteNavDetails } from '../Views/BuildQuote'; +import type { BuyFlowOrigin } from '../Views/BuildQuote/BuildQuote'; +import { UnifiedRampRoutingType } from '../../../../reducers/fiatOrders'; +import { createRampUnsupportedModalNavigationDetails } from '../components/RampUnsupportedModal/RampUnsupportedModal'; +import { createEligibilityFailedModalNavigationDetails } from '../components/EligibilityFailedModal/EligibilityFailedModal'; +import { + resolveRampControllerAssetId, + type TokenForResolve, +} from './resolveRampControllerAssetId'; + +/** + * How {@link navigateToRampBuy} should route when unified routing is overridden + * or legacy V1 is off (`mode` applies after eligibility checks and V2 branches). + */ +export enum NavigateToRampBuyMode { + AGGREGATOR = 'AGGREGATOR', + DEPOSIT = 'DEPOSIT', +} + +export interface NavigateToRampBuyOptions { + mode?: NavigateToRampBuyMode; + overrideUnifiedRouting?: boolean; + buyFlowOrigin?: BuyFlowOrigin; +} + +/** + * React Navigation handle used by ramp buy routing. Pass `useNavigation()` from + * a screen inside a navigator that hosts RAMP / DEPOSIT routes, or an equivalent + * object (e.g. `NavigationService.navigation` where typed compatibly). + */ +export type NavigateToRampBuyNavigation = Pick< + NavigationProp, + 'navigate' +>; + +/** + * Redux- and controller-derived inputs for buy routing. In React screens, wire + * these from hooks/selectors (`useRampsUnifiedV1Enabled`, `getRampRoutingDecision`, etc.). + */ +export interface NavigateToRampBuyDeps { + isRampsUnifiedV1Enabled: boolean; + isRampsUnifiedV2Enabled: boolean; + rampRoutingDecision: UnifiedRampRoutingType | null; + rampsTokensAll: TokenForResolve[]; + setSelectedToken: (assetId: string) => void; +} + +/** + * Imperatively starts the **buy** ramp experience with the same routing rules as + * {@link useRampNavigation}'s `goToBuy` (eligibility modals, Unified Buy 2 / BuildQuote, + * token selection, V1 deposit vs aggregator, override modes). + * + * Prefer `useRampNavigation` inside React components; use `navigateToRampBuy` when you + * have `navigation` and {@link NavigateToRampBuyDeps} outside that hook (e.g. tests). + * + * When `isRampsUnifiedV2Enabled` is true and `overrideUnifiedRouting` is false, an + * `intent.assetId` routes to BuildQuote (after resolving against `rampsTokensAll`); + * missing `assetId` routes to token selection. UB2 takes precedence over V1 when both + * apply and `assetId` is present. + * + * @param navigation - Navigation object with `navigate` (e.g. from `useNavigation()`). + * @param intent - Optional ramp intent (e.g. `assetId` for pre-selected token). + * @param options - `buyFlowOrigin` for BuildQuote, `overrideUnifiedRouting` to force legacy paths. + * @param deps - Feature flags, routing decision, token list, and `setSelectedToken`. + */ +export function navigateToRampBuy( + navigation: NavigateToRampBuyNavigation, + intent: RampIntent | undefined, + options: NavigateToRampBuyOptions | undefined, + deps: NavigateToRampBuyDeps, +): void { + const { + mode = NavigateToRampBuyMode.AGGREGATOR, + overrideUnifiedRouting = false, + } = options ?? {}; + + const { + isRampsUnifiedV1Enabled, + isRampsUnifiedV2Enabled, + rampRoutingDecision, + rampsTokensAll, + setSelectedToken, + } = deps; + + const isUnifiedRoutingEnabled = + (isRampsUnifiedV1Enabled || isRampsUnifiedV2Enabled) && + !overrideUnifiedRouting; + + if (isUnifiedRoutingEnabled) { + if (rampRoutingDecision === UnifiedRampRoutingType.ERROR) { + navigation.navigate(...createEligibilityFailedModalNavigationDetails()); + return; + } + + if (rampRoutingDecision === UnifiedRampRoutingType.UNSUPPORTED) { + navigation.navigate(...createRampUnsupportedModalNavigationDetails()); + return; + } + } + + if (isRampsUnifiedV2Enabled && intent?.assetId && !overrideUnifiedRouting) { + const controllerAssetId = resolveRampControllerAssetId( + intent.assetId, + rampsTokensAll, + ); + try { + setSelectedToken(controllerAssetId); + } catch { + // Token may not be in controller's list yet (still loading). + // Navigate anyway — BuildQuote will handle the missing token. + } + navigation.navigate( + ...createBuildQuoteNavDetails({ + assetId: controllerAssetId, + buyFlowOrigin: options?.buyFlowOrigin, + }), + ); + return; + } + + if (isRampsUnifiedV2Enabled && !intent?.assetId && !overrideUnifiedRouting) { + navigation.navigate(...createTokenSelectionNavDetails()); + return; + } + + if (isRampsUnifiedV1Enabled && !overrideUnifiedRouting) { + if (!intent?.assetId) { + navigation.navigate(...createTokenSelectionNavDetails()); + return; + } + + if (rampRoutingDecision === null) { + navigation.navigate(...createTokenSelectionNavDetails()); + return; + } + + if (rampRoutingDecision === UnifiedRampRoutingType.DEPOSIT) { + navigation.navigate(...createDepositNavigationDetails(intent)); + } else if (rampRoutingDecision === UnifiedRampRoutingType.AGGREGATOR) { + navigation.navigate( + ...createRampNavigationDetails(AggregatorRampType.BUY, intent), + ); + } + return; + } + + if (mode === NavigateToRampBuyMode.DEPOSIT) { + navigation.navigate(...createDepositNavigationDetails(intent)); + } else { + navigation.navigate( + ...createRampNavigationDetails(AggregatorRampType.BUY, intent), + ); + } +}