diff --git a/app/components/Base/Keypad/index.js b/app/components/Base/Keypad/index.js index 61fae850d21..49d80e02ee1 100644 --- a/app/components/Base/Keypad/index.js +++ b/app/components/Base/Keypad/index.js @@ -91,6 +91,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress1} + accessibilityRole="button" + accessible > 1 @@ -98,6 +100,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress2} + accessibilityRole="button" + accessible > 2 @@ -105,6 +109,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress3} + accessibilityRole="button" + accessible > 3 @@ -114,6 +120,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress4} + accessibilityRole="button" + accessible > 4 @@ -121,6 +129,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress5} + accessibilityRole="button" + accessible > 5 @@ -128,6 +138,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress6} + accessibilityRole="button" + accessible > 6 @@ -137,6 +149,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress7} + accessibilityRole="button" + accessible > 7 @@ -144,6 +158,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress8} + accessibilityRole="button" + accessible > 8 @@ -151,6 +167,8 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress9} + accessibilityRole="button" + accessible > 9 @@ -167,10 +185,13 @@ function KeypadComponent({ style={digitButtonStyle} textStyle={digitTextStyle} onPress={handleKeypadPress0} + accessibilityRole="button" + accessible > 0 ( const TransactionsHome = () => ( + ); @@ -221,13 +227,10 @@ const SettingsFlow = () => ( component={SecuritySettings} options={SecuritySettings.navigationOptions} /> + - ( ); -const FiatOnRampAggregator = () => ( - - +const Ramps = ({ rampType }) => ( + + + + + + + - - - - - + ); +Ramps.propTypes = { + rampType: PropTypes.string, +}; + const Swaps = () => ( ( - + + {() => } + + + {() => } + ({ activeTabUrl: getActiveTabUrl(state), chainId: selectChainId(state), tokenList: selectTokenList(state), - isNativeTokenBuySupported: isNetworkBuyNativeTokenSupported( + isNativeTokenBuySupported: isNetworkRampNativeTokenSupported( selectChainId(state), getRampNetworks(state), ), diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx index e95c3a3a1f6..c170b779095 100644 --- a/app/components/UI/AssetElement/index.tsx +++ b/app/components/UI/AssetElement/index.tsx @@ -4,7 +4,7 @@ import { TouchableOpacity, StyleSheet, Platform } from 'react-native'; import Text, { TextVariant, } from '../../../component-library/components/Texts/Text'; -import SkeletonText from '../Ramp/components/SkeletonText'; +import SkeletonText from '../Ramp/common/components/SkeletonText'; import { TokenI } from '../Tokens/types'; import generateTestId from '../../../../wdio/utils/generateTestId'; import { getAssetTestId } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 9e286956034..8ecfd83f691 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1458,7 +1458,7 @@ export function getSwapsQuotesNavbar(navigation, route, themeColors) { export function getFiatOnRampAggNavbar( navigation, - { title, showBack = true } = {}, + { title, showBack = true, showCancel = true } = {}, themeColors, onCancel, ) { @@ -1519,19 +1519,24 @@ export function getFiatOnRampAggNavbar( ); }, - headerRight: () => ( - { - navigation.dangerouslyGetParent()?.pop(); - onCancel?.(); - }} - style={styles.closeButton} - accessibilityRole="button" - accessible - > - {navigationCancelText} - - ), + headerRight: () => { + if (!showCancel) return ; + return ( + { + navigation.dangerouslyGetParent()?.pop(); + onCancel?.(); + }} + style={styles.closeButton} + accessibilityRole="button" + accessible + > + + {navigationCancelText} + + + ); + }, headerStyle: innerStyles.headerStyle, headerTitleStyle: innerStyles.headerTitleStyle, }; diff --git a/app/components/UI/Ramp/Views/OrderDetails.tsx b/app/components/UI/Ramp/Views/OrderDetails.tsx deleted file mode 100644 index 9cb0fb1c3ea..00000000000 --- a/app/components/UI/Ramp/Views/OrderDetails.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { RefreshControl } from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; -import { useNavigation } from '@react-navigation/native'; -import { Order } from '@consensys/on-ramp-sdk'; -import { ScrollView } from 'react-native-gesture-handler'; -import useAnalytics from '../hooks/useAnalytics'; -import useThunkDispatch from '../../../hooks/useThunkDispatch'; -import ScreenLayout from '../components/ScreenLayout'; -import OrderDetail from '../components/OrderDetails'; -import StyledButton from '../../StyledButton'; -import { getOrderById, updateFiatOrder } from '../../../../reducers/fiatOrders'; -import { strings } from '../../../../../locales/i18n'; -import { getFiatOnRampAggNavbar } from '../../Navbar'; -import Routes from '../../../../constants/navigation/Routes'; -import { processFiatOrder } from '..'; -import { - createNavigationDetails, - useParams, -} from '../../../../util/navigation/navUtils'; -import { useTheme } from '../../../../util/theme'; -import Logger from '../../../../util/Logger'; -import { - selectNetworkConfigurations, - selectProviderConfig, -} from '../../../../selectors/networkController'; - -interface OrderDetailsParams { - orderId?: string; -} - -export const createOrderDetailsNavDetails = - createNavigationDetails( - Routes.FIAT_ON_RAMP_AGGREGATOR.ORDER_DETAILS, - ); - -const OrderDetails = () => { - const trackEvent = useAnalytics(); - const providerConfig = useSelector(selectProviderConfig); - const networkConfigurations = useSelector(selectNetworkConfigurations); - const params = useParams(); - const order = useSelector((state) => getOrderById(state, params.orderId)); - const { colors } = useTheme(); - const navigation = useNavigation(); - const dispatch = useDispatch(); - const dispatchThunk = useThunkDispatch(); - - const [isRefreshing, setIsRefreshing] = useState(false); - - useEffect(() => { - navigation.setOptions( - getFiatOnRampAggNavbar( - navigation, - { - title: strings('fiat_on_ramp_aggregator.order_details.details_main'), - }, - colors, - ), - ); - }, [colors, navigation]); - - useEffect(() => { - if (order) { - trackEvent('ONRAMP_PURCHASE_DETAILS_VIEWED', { - purchase_status: order.state, - provider_onramp: (order.data as Order)?.provider.name, - payment_method_id: (order.data as Order)?.paymentMethod?.id, - currency_destination: order.cryptocurrency, - currency_source: order.currency, - chain_id_destination: order.network, - order_type: order.orderType, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [trackEvent]); - - const dispatchUpdateFiatOrder = useCallback( - (updatedOrder) => { - dispatch(updateFiatOrder(updatedOrder)); - }, - [dispatch], - ); - - const handleOnRefresh = useCallback(async () => { - if (!order) return; - try { - setIsRefreshing(true); - await processFiatOrder(order, dispatchUpdateFiatOrder, dispatchThunk, { - forced: true, - }); - } catch (error) { - Logger.error(error as Error, { - message: 'FiatOrders::OrderDetails error while processing order', - order, - }); - } finally { - setIsRefreshing(false); - } - }, [dispatchThunk, dispatchUpdateFiatOrder, order]); - - const handleMakeAnotherPurchase = useCallback(() => { - navigation.goBack(); - navigation.navigate(Routes.FIAT_ON_RAMP_AGGREGATOR.ID); - }, [navigation]); - - if (!order) { - return ; - } - - return ( - - - } - > - - - - - - - - - {strings( - 'fiat_on_ramp_aggregator.order_details.another_purchase', - )} - - - - - - ); -}; - -export default OrderDetails; diff --git a/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.constants.ts b/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.constants.ts new file mode 100644 index 00000000000..15723c1e977 --- /dev/null +++ b/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.constants.ts @@ -0,0 +1,154 @@ +import { + Country, + CryptoCurrency, + FiatCurrency, + Payment, +} from '@consensys/on-ramp-sdk'; + +export const mockCryptoCurrenciesData = [ + { + id: '2', + idv2: '3', + network: {}, + symbol: 'ETH', + logo: 'some_random_logo_url', + decimals: 8, + address: '0xabc', + name: 'Ethereum', + limits: ['0.001', '8'], + }, + { + id: '3', + idv2: '4', + network: {}, + symbol: 'UNI', + logo: 'uni_logo_url', + decimals: 8, + address: '0x1a2b3c', + name: 'Uniswap', + limits: ['0.001', '8'], + }, +] as CryptoCurrency[]; + +export const mockFiatCurrenciesData = [ + { + id: '2', + symbol: 'USD', + name: 'US Dollar', + decimals: 2, + denomSymbol: '$', + }, + { + id: '3', + symbol: 'EUR', + name: 'Euro', + decimals: 2, + denomSymbol: '€', + }, +] as FiatCurrency[]; + +export const mockPaymentMethods = [ + { + id: '/payments/credit-debit-card', + paymentType: 'credit-debit-card', + name: 'Credit or Debit Card', + score: 8, + icons: [ + { + type: 'materialIcons', + name: 'card', + }, + ], + logo: { + light: [ + 'https://on-ramp.metafi-dev.codefi.network/assets/Mastercard-regular@3x.png', + 'https://on-ramp.metafi-dev.codefi.network/assets/Visa-regular@3x.png', + ], + dark: [ + 'https://on-ramp.metafi-dev.codefi.network/assets/Mastercard@3x.png', + 'https://on-ramp.metafi-dev.codefi.network/assets/Visa@3x.png', + ], + }, + disclaimer: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua', + delay: [5, 10], + amountTier: [1, 3], + translation: 'debit-credit-card', + }, + { + id: '/payments/apple-pay', + paymentType: 'apple-pay', + name: 'Apple Pay', + score: 6, + icons: [ + { + type: 'fontAwesome', + name: 'apple', + }, + ], + logo: { + light: [ + 'https://on-ramp.metafi-dev.codefi.network/assets/Visa-regular@3x.png', + 'https://on-ramp.metafi-dev.codefi.network/assets/Mastercard-regular@3x.png', + ], + dark: [ + 'https://on-ramp.metafi-dev.codefi.network/assets/Visa@3x.png', + 'https://on-ramp.metafi-dev.codefi.network/assets/Mastercard@3x.png', + ], + }, + disclaimer: 'Apple credit is not supported.', + delay: [0, 0], + amountTier: [1, 3], + isApplePay: true, + translation: 'mobile_wallet', + }, + { + id: '/payments/bank-transfer', + paymentType: 'bank-transfer', + name: 'Super Instant Bank Transfer', + score: 5, + icons: [ + { + type: 'materialCommunityIcons', + name: 'bank', + }, + ], + logo: { + light: [ + 'https://on-ramp.metafi-dev.codefi.network/assets/ACHBankTransfer-regular@3x.png', + ], + dark: [ + 'https://on-ramp.metafi-dev.codefi.network/assets/ACHBankTransfer@3x.png', + ], + }, + delay: [0, 0], + amountTier: [3, 3], + supportedCurrency: ['/currencies/fiat/usd'], + translation: 'ACH', + }, +] as Partial[]; + +export const mockRegionsData = [ + { + currencies: ['/currencies/fiat/clp'], + emoji: '🇨🇱', + id: '/regions/cl', + name: 'Chile', + unsupported: false, + support: { + buy: true, + sell: true, + }, + }, + { + currencies: ['/currencies/fiat/eur'], + emoji: '🇦🇱', + id: '/regions/al', + name: 'Albania', + unsupported: false, + support: { + buy: true, + sell: true, + }, + }, +] as Country[]; diff --git a/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.styles.ts b/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.styles.ts new file mode 100644 index 00000000000..edb849d8796 --- /dev/null +++ b/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.styles.ts @@ -0,0 +1,41 @@ +import { Theme } from '../../../../../../util/theme/models'; +import { StyleSheet } from 'react-native'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + viewContainer: { + flex: 1, + }, + selectors: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + spacer: { + minWidth: 8, + }, + keypadContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + paddingBottom: 50, + backgroundColor: colors.background.alternative, + }, + cta: { + paddingTop: 12, + }, + flexRow: { + flexDirection: 'row', + }, + flagText: { + marginVertical: 3, + marginHorizontal: 0, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.test.tsx new file mode 100644 index 00000000000..09a4b0b8ae6 --- /dev/null +++ b/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.test.tsx @@ -0,0 +1,739 @@ +import React from 'react'; +import { Limits, Payment } from '@consensys/on-ramp-sdk'; +import { act, fireEvent, screen } from '@testing-library/react-native'; +import { BN } from 'ethereumjs-util'; +import { renderScreen } from '../../../../../../util/test/renderWithProvider'; +import BuildQuote from './BuildQuote'; +import useRegions from '../../hooks/useRegions'; +import { RampSDK } from '../../../common/sdk'; +import Routes from '../../../../../../constants/navigation/Routes'; +import initialBackgroundState from '../../../../../../util/test/initial-background-state.json'; +import useCryptoCurrencies from '../../hooks/useCryptoCurrencies'; +import useFiatCurrencies from '../../hooks/useFiatCurrencies'; +import usePaymentMethods from '../../hooks/usePaymentMethods'; +import { + mockCryptoCurrenciesData, + mockFiatCurrenciesData, + mockPaymentMethods, + mockRegionsData, +} from './BuildQuote.constants'; +import useLimits from '../../hooks/useLimits'; +import useAddressBalance from '../../../../../hooks/useAddressBalance/useAddressBalance'; +import useBalance from '../../../common/hooks/useBalance'; +import { toTokenMinimalUnit } from '../../../../../../util/number'; +import { RampType } from '../../../../../../reducers/fiatOrders/types'; + +const getByRoleButton = (name?: string | RegExp) => + screen.getByRole('button', { name }); + +function render(Component: React.ComponentType) { + return renderScreen( + Component, + { + name: Routes.RAMP.BUILD_QUOTE, + }, + { + state: { + engine: { + backgroundState: initialBackgroundState, + }, + }, + }, + ); +} + +const mockSetOptions = jest.fn(); +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockReset = jest.fn(); +const mockPop = jest.fn(); +const mockTrackEvent = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: mockSetOptions.mockImplementation( + actualReactNavigation.useNavigation().setOptions, + ), + goBack: mockGoBack, + reset: mockReset, + dangerouslyGetParent: () => ({ + pop: mockPop, + }), + }), + }; +}); + +const mockQueryGetCountries = jest.fn(); +const mockClearUnsupportedRegion = jest.fn(); + +const mockUseRegionsInitialValues: Partial> = { + data: mockRegionsData, + isFetching: false, + error: null, + query: mockQueryGetCountries, + selectedRegion: mockRegionsData[0], + unsupportedRegion: undefined, + clearUnsupportedRegion: mockClearUnsupportedRegion, +}; + +let mockUseRegionsValues: Partial> = { + ...mockUseRegionsInitialValues, +}; +jest.mock('../../hooks/useRegions', () => jest.fn(() => mockUseRegionsValues)); + +const mockGetCryptoCurrencies = jest.fn(); + +const mockUseCryptoCurrenciesInitialValues: Partial< + ReturnType +> = { + cryptoCurrencies: mockCryptoCurrenciesData, + errorCryptoCurrencies: null, + isFetchingCryptoCurrencies: false, + queryGetCryptoCurrencies: mockGetCryptoCurrencies, +}; + +let mockUseCryptoCurrenciesValues: Partial< + ReturnType +> = { + ...mockUseCryptoCurrenciesInitialValues, +}; +jest.mock('../../hooks/useCryptoCurrencies', () => + jest.fn(() => mockUseCryptoCurrenciesValues), +); + +const mockGetFiatCurrencies = jest.fn(); +const mockGetDefaultFiatCurrencies = jest.fn(); + +const mockUseFiatCurrenciesInitialValues: Partial< + ReturnType +> = { + defaultFiatCurrency: mockFiatCurrenciesData[0], + queryDefaultFiatCurrency: mockGetDefaultFiatCurrencies, + fiatCurrencies: mockFiatCurrenciesData, + queryGetFiatCurrencies: mockGetFiatCurrencies, + errorFiatCurrency: null, + isFetchingFiatCurrency: false, + currentFiatCurrency: mockFiatCurrenciesData[0], +}; + +let mockUseFiatCurrenciesValues: Partial> = + { + ...mockUseFiatCurrenciesInitialValues, + }; +jest.mock('../../hooks/useFiatCurrencies', () => + jest.fn(() => mockUseFiatCurrenciesValues), +); + +const mockQueryGetPaymentMethods = jest.fn(); + +const mockUsePaymentMethodsInitialValues: Partial< + ReturnType +> = { + data: mockPaymentMethods as Payment[], + isFetching: false, + error: null, + query: mockQueryGetPaymentMethods, + currentPaymentMethod: mockPaymentMethods[0] as Payment, +}; + +let mockUsePaymentMethodsValues = { + ...mockUsePaymentMethodsInitialValues, +}; + +jest.mock('../../hooks/usePaymentMethods', () => + jest.fn(() => mockUsePaymentMethodsValues), +); + +const MAX_LIMIT = 4; +const VALID_AMOUNT = 3; +const MIN_LIMIT = 2; +const mockUseLimitsInitialValues: Partial> = { + limits: { + minAmount: MIN_LIMIT, + maxAmount: MAX_LIMIT, + feeDynamicRate: 1, + feeFixedRate: 1, + quickAmounts: [100, 500, 1000], + }, + isAmountBelowMinimum: jest + .fn() + .mockImplementation((amount) => amount < MIN_LIMIT), + isAmountAboveMaximum: jest + .fn() + .mockImplementation((amount) => amount > MAX_LIMIT), + isAmountValid: jest.fn(), +}; + +let mockUseLimitsValues = { + ...mockUseLimitsInitialValues, +}; + +jest.mock('../../hooks/useLimits', () => jest.fn(() => mockUseLimitsValues)); + +const mockUseAddressBalanceInitialValue: ReturnType = + { + addressBalance: '5.36385 ETH', + }; + +jest.mock('../../../../../hooks/useAddressBalance/useAddressBalance', () => + jest.fn(() => mockUseAddressBalanceInitialValue), +); + +const mockUseBalanceInitialValue: Partial> = { + balanceFiat: '$27.02', + balanceBN: toTokenMinimalUnit('5.36385', 18) as BN, +}; + +const mockUseBalanceValues = { + ...mockUseBalanceInitialValue, +}; + +jest.mock('../../../common/hooks/useBalance', () => + jest.fn(() => mockUseBalanceValues), +); + +const mockSetSelectedRegion = jest.fn(); +const mockSetSelectedPaymentMethodId = jest.fn(); +const mockSetSelectedAsset = jest.fn(); +const mockSetSelectedFiatCurrencyId = jest.fn(); + +const mockUseRampSDKInitialValues: Partial = { + selectedPaymentMethodId: mockPaymentMethods[0].id, + selectedRegion: mockRegionsData[0], + setSelectedRegion: mockSetSelectedRegion, + selectedAsset: mockCryptoCurrenciesData[0], + setSelectedAsset: mockSetSelectedAsset, + selectedFiatCurrencyId: mockFiatCurrenciesData[0].id, + setSelectedFiatCurrencyId: mockSetSelectedFiatCurrencyId, + selectedChainId: '1', + selectedNetworkName: 'Ethereum', + sdkError: undefined, + setSelectedPaymentMethodId: mockSetSelectedPaymentMethodId, + isBuy: true, + isSell: false, +}; + +let mockUseRampSDKValues: Partial = { + ...mockUseRampSDKInitialValues, +}; + +jest.mock('../../../common/sdk', () => ({ + ...jest.requireActual('../../../common/sdk'), + useRampSDK: () => mockUseRampSDKValues, +})); + +let mockUseParamsValues: { + showBack?: boolean; +} = { + showBack: undefined, +}; + +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), + useParams: jest.fn(() => mockUseParamsValues), +})); + +jest.mock('../../../common/hooks/useAnalytics', () => () => mockTrackEvent); + +describe('BuildQuote View', () => { + afterEach(() => { + mockNavigate.mockClear(); + mockGoBack.mockClear(); + mockSetOptions.mockClear(); + mockReset.mockClear(); + mockPop.mockClear(); + mockTrackEvent.mockClear(); + (mockUseRampSDKInitialValues.setSelectedRegion as jest.Mock).mockClear(); + }); + + beforeEach(() => { + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + }; + mockUseRegionsValues = { + ...mockUseRegionsInitialValues, + }; + mockUseCryptoCurrenciesValues = { + ...mockUseCryptoCurrenciesInitialValues, + }; + mockUseFiatCurrenciesValues = { + ...mockUseFiatCurrenciesInitialValues, + }; + mockUsePaymentMethodsValues = { + ...mockUsePaymentMethodsInitialValues, + }; + mockUseLimitsValues = { + ...mockUseLimitsInitialValues, + }; + mockUseParamsValues = { + showBack: undefined, + }; + }); + + // + // RENDER & SDK TESTS + // + it('renders correctly', async () => { + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + + mockUseRampSDKValues.isBuy = false; + mockUseRampSDKValues.isSell = true; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders correctly when sdkError is present', async () => { + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + sdkError: new Error('sdkError'), + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + isBuy: false, + isSell: true, + sdkError: new Error('sdkError in sell'), + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('navigates to home when clicking sdKError button', async () => { + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + sdkError: new Error('sdkError'), + }; + render(BuildQuote); + fireEvent.press( + screen.getByRole('button', { name: 'Return to Home Screen' }), + ); + expect(mockPop).toBeCalledTimes(1); + + mockPop.mockReset(); + + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + isBuy: false, + isSell: true, + sdkError: new Error('sdkError in sell'), + }; + render(BuildQuote); + fireEvent.press( + screen.getByRole('button', { name: 'Return to Home Screen' }), + ); + expect(mockPop).toBeCalledTimes(1); + }); + + it('calls setOptions when rendering', async () => { + render(BuildQuote); + expect(mockSetOptions).toBeCalledTimes(1); + + mockSetOptions.mockReset(); + + mockUseRampSDKValues.isBuy = false; + mockUseRampSDKValues.isSell = true; + render(BuildQuote); + expect(mockSetOptions).toBeCalledTimes(1); + }); + + it('navigates and tracks event on cancel button press', async () => { + render(BuildQuote); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockPop).toHaveBeenCalled(); + expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', { + chain_id_destination: '1', + location: 'Amount to Buy Screen', + }); + + mockPop.mockReset(); + mockTrackEvent.mockReset(); + + mockUseRampSDKValues.isBuy = false; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.rampType = RampType.SELL; + render(BuildQuote); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockPop).toHaveBeenCalled(); + expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + chain_id_source: '1', + location: 'Amount to Sell Screen', + }); + }); + + describe('Regions data', () => { + it('renders the loading page when regions are loading', async () => { + mockUseRegionsValues = { + ...mockUseRegionsInitialValues, + isFetching: true, + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders an error page when there is a region error', async () => { + mockUseRegionsValues = { + ...mockUseRegionsInitialValues, + error: 'Test error', + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('queries region data when error CTA is clicked', async () => { + mockUseRegionsValues = { + ...mockUseRegionsInitialValues, + error: 'Test error', + }; + render(BuildQuote); + fireEvent.press(screen.getByRole('button', { name: 'Try again' })); + expect(mockQueryGetCountries).toBeCalledTimes(1); + }); + + it('calls setSelectedRegion when selecting a region', async () => { + render(BuildQuote); + await act(async () => + fireEvent.press( + getByRoleButton(mockUseRegionsValues.selectedRegion?.emoji), + ), + ); + await act(async () => + fireEvent.press(getByRoleButton(mockRegionsData[1].name)), + ); + expect(mockSetSelectedRegion).toHaveBeenCalledWith(mockRegionsData[1]); + }); + }); + + describe('Crypto Currency Data', () => { + it('renders the loading page when cryptos are loading', async () => { + mockUseCryptoCurrenciesValues = { + ...mockUseCryptoCurrenciesInitialValues, + isFetchingCryptoCurrencies: true, + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders a special error page if crypto currencies are not available', async () => { + mockUseCryptoCurrenciesValues = { + ...mockUseCryptoCurrenciesInitialValues, + cryptoCurrencies: [], + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + + mockUseRampSDKValues.isBuy = false; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.rampType = RampType.SELL; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders an error page when there is a cryptos error', async () => { + mockUseCryptoCurrenciesValues = { + ...mockUseCryptoCurrenciesInitialValues, + errorCryptoCurrencies: 'Test error', + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('queries crypto data when error CTA is clicked', async () => { + mockUseCryptoCurrenciesValues = { + ...mockUseCryptoCurrenciesInitialValues, + errorCryptoCurrencies: 'Test error', + }; + render(BuildQuote); + fireEvent.press(screen.getByRole('button', { name: 'Try again' })); + expect(mockGetCryptoCurrencies).toBeCalledTimes(1); + }); + + it('calls setSelectedAsset when selecting a crypto', async () => { + render(BuildQuote); + fireEvent.press(getByRoleButton(mockCryptoCurrenciesData[0].name)); + fireEvent.press(getByRoleButton(mockCryptoCurrenciesData[1].name)); + expect(mockSetSelectedAsset).toHaveBeenCalledWith( + mockCryptoCurrenciesData[1], + ); + }); + }); + + describe('Payment Method Data', () => { + it('renders the loading page when payment methods are loading', async () => { + mockUsePaymentMethodsValues = { + ...mockUsePaymentMethodsInitialValues, + isFetching: true, + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders an error page when there is a payment method error', async () => { + mockUsePaymentMethodsValues = { + ...mockUsePaymentMethodsInitialValues, + error: 'Test error', + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('queries for payment methods when error CTA is clicked', async () => { + mockUsePaymentMethodsValues = { + ...mockUsePaymentMethodsInitialValues, + error: 'Test error', + }; + render(BuildQuote); + fireEvent.press(screen.getByRole('button', { name: 'Try again' })); + expect(mockQueryGetPaymentMethods).toBeCalledTimes(1); + }); + + it('calls setSelectedPaymentMethodId when selecting a payment method', async () => { + render(BuildQuote); + fireEvent.press(getByRoleButton(mockPaymentMethods[0].name)); + fireEvent.press(getByRoleButton(mockPaymentMethods[1].name)); + expect(mockSetSelectedPaymentMethodId).toHaveBeenCalledWith( + mockPaymentMethods[1]?.id, + ); + }); + }); + + describe('Fiat Currency Data', () => { + it('renders the loading page when fiats are loading', async () => { + mockUseFiatCurrenciesValues = { + ...mockUseFiatCurrenciesInitialValues, + isFetchingFiatCurrency: true, + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders an error page when there is a fiat error', async () => { + mockUseFiatCurrenciesValues = { + ...mockUseFiatCurrenciesInitialValues, + errorFiatCurrency: 'Test error', + }; + render(BuildQuote); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('queries for fiats when error CTA is clicked', async () => { + mockUseFiatCurrenciesValues = { + ...mockUseFiatCurrenciesInitialValues, + errorFiatCurrency: 'Test error', + }; + render(BuildQuote); + fireEvent.press(screen.getByRole('button', { name: 'Try again' })); + expect(mockGetFiatCurrencies).toBeCalledTimes(1); + }); + + it('calls setSelectedFiatCurrencyId when selecting a new fiat', async () => { + render(BuildQuote); + fireEvent.press(getByRoleButton(mockFiatCurrenciesData[0].symbol)); + fireEvent.press(getByRoleButton(mockFiatCurrenciesData[1].symbol)); + expect(mockSetSelectedFiatCurrencyId).toHaveBeenCalledWith( + mockFiatCurrenciesData[1]?.id, + ); + }); + }); + + describe('Amount to buy input', () => { + it('updates the amount input', async () => { + render(BuildQuote); + const initialAmount = '0'; + const validAmount = VALID_AMOUNT.toString(); + const symbol = + mockUseFiatCurrenciesValues.currentFiatCurrency?.denomSymbol; + fireEvent.press(getByRoleButton(`${symbol}${initialAmount}`)); + fireEvent.press(getByRoleButton(validAmount)); + expect(getByRoleButton(`${symbol}${validAmount}`)).toBeTruthy(); + }); + + it('updates the amount input with quick amount buttons', async () => { + render(BuildQuote); + const initialAmount = '0'; + const quickAmount = + mockUseLimitsInitialValues?.limits?.quickAmounts?.[0].toString(); + const symbol = + mockUseFiatCurrenciesValues.currentFiatCurrency?.denomSymbol; + fireEvent.press(getByRoleButton(`${symbol}${initialAmount}`)); + fireEvent.press(getByRoleButton(`${symbol}${quickAmount}`)); + expect( + screen.queryAllByRole('button', { name: `${symbol}${quickAmount}` }), + ).toHaveLength(2); + }); + + it('validates the max limit', () => { + render(BuildQuote); + const initialAmount = '0'; + const invalidMaxAmount = (MAX_LIMIT + 1).toString(); + const denomSymbol = + mockUseFiatCurrenciesValues.currentFiatCurrency?.denomSymbol; + fireEvent.press(getByRoleButton(`${denomSymbol}${initialAmount}`)); + fireEvent.press(getByRoleButton(invalidMaxAmount)); + expect( + screen.getByText(`Maximum deposit is ${denomSymbol}${MAX_LIMIT}`), + ).toBeTruthy(); + }); + + it('validates the min limit', () => { + render(BuildQuote); + const initialAmount = '0'; + const invalidMinAmount = (MIN_LIMIT - 1).toString(); + const denomSymbol = + mockUseFiatCurrenciesValues.currentFiatCurrency?.denomSymbol; + fireEvent.press(getByRoleButton(`${denomSymbol}${initialAmount}`)); + fireEvent.press(getByRoleButton(invalidMinAmount)); + expect( + screen.getByText(`Minimum deposit is ${denomSymbol}${MIN_LIMIT}`), + ).toBeTruthy(); + }); + }); + + describe('Amount to sell input', () => { + beforeEach(() => { + mockUseRampSDKValues.isBuy = false; + mockUseRampSDKValues.isSell = true; + mockUseLimitsValues = { + ...mockUseLimitsInitialValues, + limits: { + ...(mockUseLimitsInitialValues.limits as Limits), + quickAmounts: undefined, + }, + }; + }); + + it('updates the amount input', async () => { + render(BuildQuote); + const initialAmount = '0'; + const validAmount = VALID_AMOUNT.toString(); + const symbol = mockUseRampSDKValues.selectedAsset?.symbol; + fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); + fireEvent.press(getByRoleButton(validAmount)); + expect(getByRoleButton(`${validAmount} ${symbol}`)).toBeTruthy(); + }); + + it('validates the max limit', () => { + render(BuildQuote); + const initialAmount = '0'; + const invalidMaxAmount = (MAX_LIMIT + 1).toString(); + const symbol = mockUseRampSDKValues.selectedAsset?.symbol; + fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); + fireEvent.press(getByRoleButton(invalidMaxAmount)); + expect( + screen.getByText('Enter a smaller amount to continue'), + ).toBeTruthy(); + }); + + it('validates the min limit', () => { + render(BuildQuote); + const initialAmount = '0'; + const invalidMinAmount = (MIN_LIMIT - 1).toString(); + const symbol = mockUseRampSDKValues.selectedAsset?.symbol; + fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); + fireEvent.press(getByRoleButton(invalidMinAmount)); + expect( + screen.getByText('Enter a larger amount to continue'), + ).toBeTruthy(); + }); + + it('validates the insufficient balance', () => { + mockUseLimitsValues.limits = { + ...mockUseLimitsValues.limits, + maxAmount: 10, + } as Limits; + + mockUseBalanceValues.balanceBN = toTokenMinimalUnit( + '5', + mockUseRampSDKValues.selectedAsset?.decimals || 18, + ) as BN; + render(BuildQuote); + const initialAmount = '0'; + const overBalanceAmout = '6'; + const symbol = mockUseRampSDKValues.selectedAsset?.symbol; + fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); + fireEvent.press(getByRoleButton(overBalanceAmout)); + expect( + screen.getByText('This amount is higher than your balance'), + ).toBeTruthy(); + }); + }); + + // + // SUBMIT BUTTON TEST + // + it('Directs the user to the quotes page with correct parameters', () => { + render(BuildQuote); + + const submitBtn = getByRoleButton('Get quotes'); + expect(submitBtn).toBeTruthy(); + expect(submitBtn.props.disabled).toBe(true); + + const initialAmount = '0'; + const validAmount = VALID_AMOUNT.toString(); + const denomSymbol = + mockUseFiatCurrenciesValues.currentFiatCurrency?.denomSymbol; + fireEvent.press(getByRoleButton(`${denomSymbol}${initialAmount}`)); + fireEvent.press(getByRoleButton(validAmount)); + fireEvent.press(getByRoleButton('Done')); + expect(submitBtn.props.disabled).toBe(false); + + fireEvent.press(submitBtn); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.QUOTES, { + amount: VALID_AMOUNT, + asset: mockUseRampSDKValues.selectedAsset, + fiatCurrency: mockUseFiatCurrenciesValues.currentFiatCurrency, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_QUOTES_REQUESTED', { + amount: VALID_AMOUNT, + currency_source: mockUseFiatCurrenciesValues?.currentFiatCurrency?.symbol, + currency_destination: mockUseRampSDKValues?.selectedAsset?.symbol, + payment_method_id: mockUsePaymentMethodsValues.currentPaymentMethod?.id, + chain_id_destination: '1', + location: 'Amount to Buy Screen', + }); + }); + + it('Directs the user to the sell quotes page with correct parameters', () => { + mockUseRampSDKValues.isBuy = false; + mockUseRampSDKValues.isSell = true; + render(BuildQuote); + + const submitBtn = getByRoleButton('Get quotes'); + expect(submitBtn).toBeTruthy(); + expect(submitBtn.props.disabled).toBe(true); + + const initialAmount = '0'; + const validAmount = VALID_AMOUNT.toString(); + const symbol = mockUseRampSDKValues.selectedAsset?.symbol; + fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`)); + fireEvent.press(getByRoleButton(validAmount)); + fireEvent.press(getByRoleButton('Done')); + expect(submitBtn.props.disabled).toBe(false); + + fireEvent.press(submitBtn); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.QUOTES, { + amount: VALID_AMOUNT, + asset: mockUseRampSDKValues.selectedAsset, + fiatCurrency: mockUseFiatCurrenciesValues.currentFiatCurrency, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_QUOTES_REQUESTED', { + amount: VALID_AMOUNT, + currency_source: mockUseRampSDKValues?.selectedAsset?.symbol, + currency_destination: + mockUseFiatCurrenciesValues?.currentFiatCurrency?.symbol, + payment_method_id: mockUsePaymentMethodsValues.currentPaymentMethod?.id, + chain_id_source: '1', + location: 'Amount to Sell Screen', + }); + }); +}); diff --git a/app/components/UI/Ramp/Views/AmountToBuy.tsx b/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.tsx similarity index 50% rename from app/components/UI/Ramp/Views/AmountToBuy.tsx rename to app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.tsx index 50b15244095..14c586ca170 100644 --- a/app/components/UI/Ramp/Views/AmountToBuy.tsx +++ b/app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.tsx @@ -5,119 +5,92 @@ import React, { useRef, useState, } from 'react'; -import { StyleSheet, Pressable, View, BackHandler } from 'react-native'; +import { Pressable, View, BackHandler } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { useNavigation } from '@react-navigation/native'; -import { CryptoCurrency } from '@consensys/on-ramp-sdk'; - -import { useFiatOnRampSDK } from '../sdk'; -import useSDKMethod from '../hooks/useSDKMethod'; -import usePaymentMethods from '../hooks/usePaymentMethods'; -import useRegions from '../hooks/useRegions'; -import useAnalytics from '../hooks/useAnalytics'; - -import useModalHandler from '../../../Base/hooks/useModalHandler'; -import Text from '../../../Base/Text'; -import BaseListItem from '../../../Base/ListItem'; -import BaseSelectorButton from '../../../Base/SelectorButton'; -import StyledButton from '../../StyledButton'; - -import ScreenLayout from '../components/ScreenLayout'; -import Box from '../components/Box'; -import AssetSelectorButton from '../components/AssetSelectorButton'; -import PaymentMethodSelector from '../components/PaymentMethodSelector'; -import AmountInput from '../components/AmountInput'; -import Keypad from '../components/Keypad'; -import QuickAmounts from '../components/QuickAmounts'; -import AccountSelector from '../components/AccountSelector'; -import TokenIcon from '../../Swaps/components/TokenIcon'; -import CustomActionButton from '../containers/CustomActionButton'; -import TokenSelectModal from '../components/TokenSelectModal'; -import PaymentMethodModal from '../components/PaymentMethodModal'; -import PaymentMethodIcon from '../components/PaymentMethodIcon'; -import FiatSelectModal from '../components/modals/FiatSelectModal'; -import ErrorViewWithReporting from '../components/ErrorViewWithReporting'; -import RegionModal from '../components/RegionModal'; -import SkeletonText from '../components/SkeletonText'; -import ErrorView from '../components/ErrorView'; - -import { getFiatOnRampAggNavbar } from '../../Navbar'; -import { useTheme } from '../../../../util/theme'; -import { strings } from '../../../../../locales/i18n'; +import { BN } from 'ethereumjs-util'; + +import { useRampSDK } from '../../../common/sdk'; +import usePaymentMethods from '../../hooks/usePaymentMethods'; +import useRegions from '../../hooks/useRegions'; +import useAnalytics from '../../../common/hooks/useAnalytics'; +import useFiatCurrencies from '../../hooks/useFiatCurrencies'; +import useCryptoCurrencies from '../../hooks/useCryptoCurrencies'; +import useLimits from '../../hooks/useLimits'; +import useBalance from '../../../common/hooks/useBalance'; + +import useAddressBalance from '../../../../../hooks/useAddressBalance/useAddressBalance'; +import { Asset } from '../../../../../hooks/useAddressBalance/useAddressBalance.types'; +import useModalHandler from '../../../../../Base/hooks/useModalHandler'; + +import Text from '../../../../../Base/Text'; +import BaseListItem from '../../../../../Base/ListItem'; +import BaseSelectorButton from '../../../../../Base/SelectorButton'; +import StyledButton from '../../../../StyledButton'; + +import ScreenLayout from '../../../common/components/ScreenLayout'; +import Box from '../../../common/components/Box'; +import Row from '../../../common/components/Row'; +import AssetSelectorButton from '../../../common/components/AssetSelectorButton'; +import PaymentMethodSelector from '../../../common/components/PaymentMethodSelector'; +import AmountInput from '../../../common/components/AmountInput'; +import Keypad from '../../../common/components/Keypad'; +import QuickAmounts from '../../../common/components/QuickAmounts'; +import AccountSelector from '../../../common/components/AccountSelector'; +import TokenIcon from '../../../../Swaps/components/TokenIcon'; +import CustomActionButton from '../../../common/containers/CustomActionButton'; +import TokenSelectModal from '../../../common/components/TokenSelectModal'; +import PaymentMethodModal from '../../../common/components/PaymentMethodModal'; +import PaymentMethodIcon from '../../../common/components/PaymentMethodIcon'; +import FiatSelectModal from '../../../common/components/modals/FiatSelectModal'; +import ErrorViewWithReporting from '../../../common/components/ErrorViewWithReporting'; +import RegionModal from '../../../common/components/RegionModal'; +import SkeletonText from '../../../common/components/SkeletonText'; +import ErrorView from '../../../common/components/ErrorView'; + +import { NATIVE_ADDRESS } from '../../../../../../constants/on-ramp'; +import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { strings } from '../../../../../../../locales/i18n'; import { createNavigationDetails, useParams, -} from '../../../../util/navigation/navUtils'; -import Routes from '../../../../constants/navigation/Routes'; -import { Colors } from '../../../../util/theme/models'; -import { NATIVE_ADDRESS } from '../../../../constants/on-ramp'; -import { formatAmount } from '../utils'; -import { createQuotesNavDetails } from './Quotes/Quotes'; -import { Region } from '../types'; +} from '../../../../../../util/navigation/navUtils'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { formatAmount } from '../../../common/utils'; +import { createQuotesNavDetails } from '../Quotes/Quotes'; +import { Region, ScreenLocation } from '../../../common/types'; +import { useStyles } from '../../../../../../component-library/hooks'; + +import styleSheet from './BuildQuote.styles'; +import { toTokenMinimalUnit } from '../../../../../../util/number'; // TODO: Convert into typescript and correctly type const ListItem = BaseListItem as any; const SelectorButton = BaseSelectorButton as any; -interface AmountToBuyParams { +interface BuildQuoteParams { showBack?: boolean; } -export const createAmountToBuyNavDetails = - createNavigationDetails( - Routes.FIAT_ON_RAMP_AGGREGATOR.AMOUNT_TO_BUY, - ); +export const createBuildQuoteNavDetails = + createNavigationDetails(Routes.RAMP.BUILD_QUOTE); -const createStyles = (colors: Colors) => - StyleSheet.create({ - viewContainer: { - flex: 1, - }, - selectors: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - spacer: { - minWidth: 8, - }, - row: { - marginVertical: 5, - }, - keypadContainer: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - paddingBottom: 50, - backgroundColor: colors.background.alternative, - }, - cta: { - paddingTop: 12, - }, - flexRow: { - flexDirection: 'row', - }, - flagText: { - marginVertical: 3, - marginHorizontal: 0, - }, - }); - -const AmountToBuy = () => { +const BuildQuote = () => { const navigation = useNavigation(); - const params = useParams(); - const { colors } = useTheme(); - const styles = createStyles(colors); + const params = useParams(); + const { + styles, + theme: { colors }, + } = useStyles(styleSheet, {}); const trackEvent = useAnalytics(); const [amountFocused, setAmountFocused] = useState(false); const [amount, setAmount] = useState('0'); const [amountNumber, setAmountNumber] = useState(0); - const [tokens, setTokens] = useState(null); + const [amountBNMinimalUnit, setAmountBNMinimalUnit] = useState(); const [error, setError] = useState(null); const keyboardHeight = useRef(1000); const keypadOffset = useSharedValue(1000); @@ -154,10 +127,18 @@ const AmountToBuy = () => { setSelectedAsset, selectedFiatCurrencyId, setSelectedFiatCurrencyId, + selectedAddress, selectedChainId, selectedNetworkName, sdkError, - } = useFiatOnRampSDK(); + rampType, + isBuy, + isSell, + } = useRampSDK(); + + const screenLocation: ScreenLocation = isBuy + ? 'Amount to Buy Screen' + : 'Amount to Sell Screen'; const { data: regions, @@ -174,203 +155,108 @@ const AmountToBuy = () => { currentPaymentMethod, } = usePaymentMethods(); - /** - * SDK methods are called as the parameters change. - * We get - * - defaultFiatCurrency -> getDefaultFiatCurrency - * - getFiatCurrencies -> currencies - * - getCryptoCurrencies -> sdkCryptoCurrencies - * - limits -> getLimits - */ - const [ - { - data: defaultFiatCurrency, - error: errorDefaultFiatCurrency, - isFetching: isFetchingDefaultFiatCurrency, - }, + const { + defaultFiatCurrency, queryDefaultFiatCurrency, - ] = useSDKMethod( - 'getDefaultFiatCurrency', - selectedRegion?.id, - selectedPaymentMethodId, - ); - - const [ - { - data: fiatCurrencies, - error: errorFiatCurrencies, - isFetching: isFetchingFiatCurrencies, - }, + fiatCurrencies, queryGetFiatCurrencies, - ] = useSDKMethod( - 'getFiatCurrencies', - selectedRegion?.id, - selectedPaymentMethodId, - ); + errorFiatCurrency, + isFetchingFiatCurrency, + currentFiatCurrency, + } = useFiatCurrencies(); - const [ - { - data: sdkCryptoCurrencies, - error: errorSdkCryptoCurrencies, - isFetching: isFetchingSdkCryptoCurrencies, - }, + const { + cryptoCurrencies, + errorCryptoCurrencies, + isFetchingCryptoCurrencies, queryGetCryptoCurrencies, - ] = useSDKMethod( - 'getCryptoCurrencies', - selectedRegion?.id, - selectedPaymentMethodId, - selectedFiatCurrencyId, - ); + } = useCryptoCurrencies(); - const [{ data: limits }] = useSDKMethod( - 'getLimits', - selectedRegion?.id, - selectedPaymentMethodId, - selectedAsset?.id, - selectedFiatCurrencyId, - ); - - /** - * * Defaults and validation of selected values - */ - - /** - * Temporarily filter crypto currencies to match current chain id - * TODO: Remove this filter when we go multi chain. Replace `tokens` with `sdkCryptoCurrencies` - */ - useEffect(() => { - if ( - !isFetchingSdkCryptoCurrencies && - !errorSdkCryptoCurrencies && - sdkCryptoCurrencies - ) { - const filteredTokens = sdkCryptoCurrencies.filter( - (token) => Number(token.network?.chainId) === Number(selectedChainId), - ); - setTokens(filteredTokens); - } - }, [ - sdkCryptoCurrencies, - errorSdkCryptoCurrencies, - isFetchingSdkCryptoCurrencies, - selectedChainId, - ]); - - /** - * Select the default fiat currency as selected if none is selected. - */ - useEffect(() => { - if ( - !isFetchingDefaultFiatCurrency && - defaultFiatCurrency && - !selectedFiatCurrencyId - ) { - setSelectedFiatCurrencyId(defaultFiatCurrency.id); - } - }, [ - defaultFiatCurrency, - isFetchingDefaultFiatCurrency, - selectedFiatCurrencyId, - setSelectedFiatCurrencyId, - ]); + const { limits, isAmountBelowMinimum, isAmountAboveMaximum, isAmountValid } = + useLimits(); - /** - * Select the default fiat currency if current selection is not available. - */ - useEffect(() => { - if ( - !isFetchingFiatCurrencies && - !isFetchingDefaultFiatCurrency && - selectedFiatCurrencyId && - fiatCurrencies && - defaultFiatCurrency && - !fiatCurrencies.some((currency) => currency.id === selectedFiatCurrencyId) - ) { - setSelectedFiatCurrencyId(defaultFiatCurrency.id); - } - }, [ - defaultFiatCurrency, - fiatCurrencies, - isFetchingDefaultFiatCurrency, - isFetchingFiatCurrencies, - selectedFiatCurrencyId, - setSelectedFiatCurrencyId, - ]); - - /** - * Select the native crytpo currency of first of the list - * if current selection is not available. - * This is using the already filtered list of tokens. - */ - useEffect(() => { - if (tokens) { - if ( - !selectedAsset || - !tokens.find((token) => token.address === selectedAsset.address) - ) { - setSelectedAsset( - tokens.find((a) => a.address === NATIVE_ADDRESS) || tokens?.[0], - ); - } - } - }, [sdkCryptoCurrencies, selectedAsset, setSelectedAsset, tokens]); - - /** - * * Derived values - */ + const assetForBalance = + selectedAsset && selectedAsset.address !== NATIVE_ADDRESS + ? { + address: selectedAsset.address, + symbol: selectedAsset.symbol, + decimals: selectedAsset.decimals, + } + : { + isETH: true, + }; - const isFetching = - isFetchingSdkCryptoCurrencies || - isFetchingPaymentMethods || - isFetchingFiatCurrencies || - isFetchingDefaultFiatCurrency || - isFetchingRegions; + const { addressBalance } = useAddressBalance( + assetForBalance as Asset, + selectedAddress, + ); - /** - * Get the fiat currency object by id - */ - const currentFiatCurrency = useMemo(() => { - const currency = - fiatCurrencies?.find?.((curr) => curr.id === selectedFiatCurrencyId) || - defaultFiatCurrency; - return currency; - }, [fiatCurrencies, defaultFiatCurrency, selectedFiatCurrencyId]); + const { balanceFiat, balanceBN } = useBalance( + selectedAsset + ? { + address: selectedAsset.address, + decimals: selectedAsset.decimals, + } + : undefined, + ); const amountIsBelowMinimum = useMemo( - () => amountNumber !== 0 && limits && amountNumber < limits.minAmount, - [amountNumber, limits], + () => isAmountBelowMinimum(amountNumber), + [amountNumber, isAmountBelowMinimum], ); const amountIsAboveMaximum = useMemo( - () => amountNumber !== 0 && limits && amountNumber > limits.maxAmount, - [amountNumber, limits], + () => isAmountAboveMaximum(amountNumber), + [amountNumber, isAmountAboveMaximum], ); const amountIsValid = useMemo( - () => !amountIsBelowMinimum && !amountIsAboveMaximum, - [amountIsBelowMinimum, amountIsAboveMaximum], + () => isAmountValid(amountNumber), + [amountNumber, isAmountValid], ); + const hasInsufficientBalance = useMemo(() => { + if (!balanceBN || !amountBNMinimalUnit) { + return null; + } + return balanceBN.lt(amountBNMinimalUnit); + }, [balanceBN, amountBNMinimalUnit]); + + const isFetching = + isFetchingCryptoCurrencies || + isFetchingPaymentMethods || + isFetchingFiatCurrency || + isFetchingRegions; + const handleCancelPress = useCallback(() => { - trackEvent('ONRAMP_CANCELED', { - location: 'Amount to Buy Screen', - chain_id_destination: selectedChainId, - }); - }, [selectedChainId, trackEvent]); + if (isBuy) { + trackEvent('ONRAMP_CANCELED', { + location: screenLocation, + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_CANCELED', { + location: screenLocation, + chain_id_source: selectedChainId, + }); + } + }, [screenLocation, isBuy, selectedChainId, trackEvent]); useEffect(() => { navigation.setOptions( getFiatOnRampAggNavbar( navigation, { - title: strings('fiat_on_ramp_aggregator.amount_to_buy'), + title: isBuy + ? strings('fiat_on_ramp_aggregator.amount_to_buy') + : strings('fiat_on_ramp_aggregator.amount_to_sell'), showBack: params.showBack, }, colors, handleCancelPress, ), ); - }, [navigation, colors, handleCancelPress, params.showBack]); + }, [navigation, colors, handleCancelPress, params.showBack, isBuy]); /** * * Keypad style, handlers and effects @@ -407,10 +293,18 @@ const AmountToBuy = () => { const handleKeypadDone = useCallback(() => setAmountFocused(false), []); const onAmountInputPress = useCallback(() => setAmountFocused(true), []); - const handleKeypadChange = useCallback(({ value, valueAsNumber }) => { - setAmount(`${value}`); - setAmountNumber(valueAsNumber); - }, []); + const handleKeypadChange = useCallback( + ({ value, valueAsNumber }) => { + setAmount(`${value}`); + setAmountNumber(valueAsNumber); + if (isSell) { + setAmountBNMinimalUnit( + toTokenMinimalUnit(`${value}`, selectedAsset?.decimals ?? 0) as BN, + ); + } + }, + [isSell, selectedAsset?.decimals], + ); const handleQuickAmountPress = useCallback((value) => { setAmount(`${value}`); @@ -524,18 +418,34 @@ const AmountToBuy = () => { fiatCurrency: currentFiatCurrency, }), ); - trackEvent('ONRAMP_QUOTES_REQUESTED', { - currency_source: currentFiatCurrency.symbol, - currency_destination: selectedAsset.symbol, + + const analyticsPayload = { payment_method_id: selectedPaymentMethodId as string, - chain_id_destination: selectedChainId, amount: amountNumber, - location: 'Amount to Buy Screen', - }); + location: screenLocation, + }; + + if (isBuy) { + trackEvent('ONRAMP_QUOTES_REQUESTED', { + ...analyticsPayload, + currency_source: currentFiatCurrency.symbol, + currency_destination: selectedAsset.symbol, + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_QUOTES_REQUESTED', { + ...analyticsPayload, + currency_destination: currentFiatCurrency.symbol, + currency_source: selectedAsset.symbol, + chain_id_source: selectedChainId, + }); + } } }, [ + screenLocation, amountNumber, currentFiatCurrency, + isBuy, navigation, selectedAsset, selectedChainId, @@ -548,24 +458,22 @@ const AmountToBuy = () => { return null; } - if (errorSdkCryptoCurrencies) { + if (errorCryptoCurrencies) { return queryGetCryptoCurrencies(); } else if (errorPaymentMethods) { return queryGetPaymentMethods(); - } else if (errorFiatCurrencies) { + } else if (errorFiatCurrency) { + queryDefaultFiatCurrency(); return queryGetFiatCurrencies(); - } else if (errorDefaultFiatCurrency) { - return queryDefaultFiatCurrency(); } else if (errorRegions) { return queryGetRegions(); } }, [ error, errorRegions, - errorDefaultFiatCurrency, - errorFiatCurrencies, + errorFiatCurrency, errorPaymentMethods, - errorSdkCryptoCurrencies, + errorCryptoCurrencies, queryDefaultFiatCurrency, queryGetRegions, queryGetCryptoCurrencies, @@ -575,29 +483,24 @@ const AmountToBuy = () => { useEffect(() => { setError( - (errorSdkCryptoCurrencies || + (errorCryptoCurrencies || errorPaymentMethods || - errorFiatCurrencies || - errorDefaultFiatCurrency || + errorFiatCurrency || errorRegions) ?? null, ); }, [ errorRegions, - errorDefaultFiatCurrency, - errorFiatCurrencies, + errorFiatCurrency, errorPaymentMethods, - errorSdkCryptoCurrencies, + errorCryptoCurrencies, ]); if (sdkError) { return ( - + ); @@ -610,7 +513,7 @@ const AmountToBuy = () => { @@ -654,7 +557,7 @@ const AmountToBuy = () => { ); } - if (!isFetching && tokens && tokens.length === 0) { + if (!isFetching && cryptoCurrencies && cryptoCurrencies.length === 0) { return ( @@ -662,7 +565,9 @@ const AmountToBuy = () => { icon="info" title={strings('fiat_on_ramp_aggregator.no_tokens_available_title')} description={strings( - 'fiat_on_ramp_aggregator.no_tokens_available', + isBuy + ? 'fiat_on_ramp_aggregator.no_tokens_available' + : 'fiat_on_ramp_aggregator.no_sell_tokens_available', { network: selectedNetworkName || @@ -670,26 +575,42 @@ const AmountToBuy = () => { region: selectedRegion?.name, }, )} - ctaLabel={strings('fiat_on_ramp_aggregator.change_payment_method')} + ctaLabel={strings( + isBuy + ? 'fiat_on_ramp_aggregator.change_payment_method' + : 'fiat_on_ramp_aggregator.change_cash_destination', + )} ctaOnPress={showPaymentMethodsModal as () => void} - location={'Amount to Buy Screen'} + location={screenLocation} /> void} - title={strings('fiat_on_ramp_aggregator.select_payment_method')} + title={strings( + isBuy + ? 'fiat_on_ramp_aggregator.select_payment_method' + : 'fiat_on_ramp_aggregator.select_cash_destination', + )} paymentMethods={paymentMethods} selectedPaymentMethodId={selectedPaymentMethodId} selectedPaymentMethodType={currentPaymentMethod?.paymentType} onItemPress={handleChangePaymentMethod} selectedRegion={selectedRegion} - location={'Amount to Buy Screen'} + location={screenLocation} + rampType={rampType} /> ); } + let displayAmount; + if (isBuy) { + displayAmount = amountFocused ? amount : formatAmount(amountNumber); + } else { + displayAmount = `${amount} ${selectedAsset?.symbol}`; + } + return ( @@ -699,75 +620,134 @@ const AmountToBuy = () => { accessible={false} > - + - + {selectedRegion?.emoji} - - - + + + + {currentFiatCurrency?.symbol} + + + + ) : null} + + + } + assetSymbol={selectedAsset?.symbol ?? ''} + assetName={selectedAsset?.name ?? ''} + onPress={handleAssetSelectorPress} + /> + {addressBalance ? ( + + + {strings('fiat_on_ramp_aggregator.current_balance')}:{' '} + {addressBalance} + {balanceFiat ? ` ≈ ${balanceFiat}` : null} + + + ) : null} + + + {hasInsufficientBalance && ( + + + {strings('fiat_on_ramp_aggregator.insufficient_balance')} + + + )} + {!hasInsufficientBalance && amountIsBelowMinimum && limits && ( + + + {isBuy ? ( + <> + {strings('fiat_on_ramp_aggregator.minimum')}{' '} + {currentFiatCurrency?.denomSymbol} + {formatAmount(limits.minAmount)} + + ) : ( + strings('fiat_on_ramp_aggregator.enter_larger_amount') + )} + + + )} + {!hasInsufficientBalance && amountIsAboveMaximum && limits && ( + + + {isBuy ? ( + <> + {strings('fiat_on_ramp_aggregator.maximum')}{' '} + {currentFiatCurrency?.denomSymbol} + {formatAmount(limits.maxAmount)} + + ) : ( + strings('fiat_on_ramp_aggregator.enter_smaller_amount') + )} + + + )} + + } - assetSymbol={selectedAsset?.symbol ?? ''} - assetName={selectedAsset?.name ?? ''} - onPress={handleAssetSelectorPress} + name={currentPaymentMethod?.name} + onPress={showPaymentMethodsModal as () => void} /> - - - - - {amountIsBelowMinimum && limits && ( - - {strings('fiat_on_ramp_aggregator.minimum')}{' '} - {currentFiatCurrency?.denomSymbol} - {formatAmount(limits.minAmount)} - - )} - {amountIsAboveMaximum && limits && ( - - {strings('fiat_on_ramp_aggregator.maximum')}{' '} - {currentFiatCurrency?.denomSymbol} - {formatAmount(limits.maxAmount)} - - )} + - - } - name={currentPaymentMethod?.name} - onPress={showPaymentMethodsModal as () => void} - /> - + {currentPaymentMethod?.customAction ? ( { {strings('fiat_on_ramp_aggregator.get_quotes')} )} - + @@ -804,11 +786,22 @@ const AmountToBuy = () => { - + {strings('fiat_on_ramp_aggregator.done')} @@ -825,7 +818,7 @@ const AmountToBuy = () => { strings('fiat_on_ramp_aggregator.this_network'), }, )} - tokens={tokens ?? []} + tokens={cryptoCurrencies ?? []} onItemPress={handleAssetPress} /> { void} - title={strings('fiat_on_ramp_aggregator.select_payment_method')} + title={strings( + isBuy + ? 'fiat_on_ramp_aggregator.select_payment_method' + : 'fiat_on_ramp_aggregator.select_cash_destination', + )} paymentMethods={paymentMethods} selectedPaymentMethodId={selectedPaymentMethodId} selectedPaymentMethodType={currentPaymentMethod?.paymentType} onItemPress={handleChangePaymentMethod} selectedRegion={selectedRegion} - location={'Amount to Buy Screen'} + location={screenLocation} + rampType={rampType} /> void} onRegionPress={handleRegionPress} - location={'Amount to Buy Screen'} + location={screenLocation} + rampType={rampType} /> ); }; -export default AmountToBuy; +export default BuildQuote; diff --git a/app/components/UI/Ramp/buy/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/buy/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap new file mode 100644 index 00000000000..254a25230dd --- /dev/null +++ b/app/components/UI/Ramp/buy/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -0,0 +1,15684 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BuildQuote View Crypto Currency Data renders a special error page if crypto currencies are not available 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + No Tokens Available + + + + + There are currently no tokens available to purchase on Ethereum with the selected payment method. + + + + + + Change payment method + + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Crypto Currency Data renders a special error page if crypto currencies are not available 2`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to sell + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + No Tokens Available + + + + + There are currently no tokens available to sell on Ethereum with the selected cash destination. + + + + + + Change cash destination + + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Crypto Currency Data renders an error page when there is a cryptos error 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + Test error + + + + + + Try again + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Crypto Currency Data renders the loading page when cryptos are loading 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Fiat Currency Data renders an error page when there is a fiat error 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + Test error + + + + + + Try again + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats are loading 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Payment Method Data renders an error page when there is a payment method error 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + Test error + + + + + + Try again + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Payment Method Data renders the loading page when payment methods are loading 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Regions data renders an error page when there is a region error 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + Test error + + + + + + Try again + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View Regions data renders the loading page when regions are loading 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View renders correctly 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + + + + ( + + ) + + +  + + + + + + + + 🇨🇱 + + +  + + + + + + You want to buy + + + + + + + + + + + + + + + + Ethereum + + + + + + + ETH + + + +  + + + + + + + + + + + Current balance + : + + 5.36385 ETH + ≈ $27.02 + + + + Amount + + + + + + + + $ + 0 + + + + + + + + + USD + + + +  + + + + + + + + + + + Minimum deposit is + + $ + 2 + + + + + Update payment method + + + + + + + +  + + + + + Credit or Debit Card + + + + +  + + + + + + + + + + + + + + + + Get quotes + + + + + + + + + + + + $100 + + + + + $500 + + + + + $1000 + + + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + + + 4 + + + + + 5 + + + + + 6 + + + + + + + 7 + + + + + 8 + + + + + 9 + + + + + + + . + + + + + 0 + + + + +  + + + + + + + + Done + + + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View renders correctly 2`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to sell + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + + + + ( + + ) + + +  + + + + + + + + 🇨🇱 + + +  + + + + + + + + USD + + +  + + + + + + You want to sell + + + + + + + + + + + + + + + + Ethereum + + + + + + + ETH + + + +  + + + + + + + + + + + Current balance + : + + 5.36385 ETH + ≈ $27.02 + + + + Amount + + + + + + + + 0 ETH + + + + + + + + + Enter a larger amount to continue + + + + + Send your cash to + + + + + + + +  + + + + + Credit or Debit Card + + + + +  + + + + + + + + + + + + + + + + Get quotes + + + + + + + + + + + + $100 + + + + + $500 + + + + + $1000 + + + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + + + 4 + + + + + 5 + + + + + 6 + + + + + + + 7 + + + + + 8 + + + + + 9 + + + + + + + . + + + + + 0 + + + + +  + + + + + + + + Done + + + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View renders correctly when sdkError is present 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to buy + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Oops, something went wrong + + + + + sdkError + + + + + + Return to Home Screen + + + + + + + + + + + + + + + + + + +`; + +exports[`BuildQuote View renders correctly when sdkError is present 2`] = ` + + + + + + + + + + + + + + Back + + + + + + + Amount to sell + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Oops, something went wrong + + + + + sdkError in sell + + + + + + Return to Home Screen + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Ramp/buy/Views/BuildQuote/index.ts b/app/components/UI/Ramp/buy/Views/BuildQuote/index.ts new file mode 100644 index 00000000000..46349a93109 --- /dev/null +++ b/app/components/UI/Ramp/buy/Views/BuildQuote/index.ts @@ -0,0 +1 @@ +export { default } from './BuildQuote'; diff --git a/app/components/UI/Ramp/Views/Checkout.tsx b/app/components/UI/Ramp/buy/Views/Checkout.tsx similarity index 72% rename from app/components/UI/Ramp/Views/Checkout.tsx rename to app/components/UI/Ramp/buy/Views/Checkout.tsx index 25a6f9cf073..9df96822080 100644 --- a/app/components/UI/Ramp/Views/Checkout.tsx +++ b/app/components/UI/Ramp/buy/Views/Checkout.tsx @@ -5,27 +5,28 @@ import { parseUrl } from 'query-string'; import { WebView, WebViewNavigation } from 'react-native-webview'; import { useNavigation } from '@react-navigation/native'; import { Provider } from '@consensys/on-ramp-sdk'; -import { baseStyles } from '../../../../styles/common'; -import { useTheme } from '../../../../util/theme'; -import { getFiatOnRampAggNavbar } from '../../Navbar'; -import { useFiatOnRampSDK, SDK } from '../sdk'; +import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; +import { baseStyles } from '../../../../../styles/common'; +import { useTheme } from '../../../../../util/theme'; +import { getFiatOnRampAggNavbar } from '../../../Navbar'; +import { useRampSDK, SDK } from '../../common/sdk'; import { addFiatCustomIdData, removeFiatCustomIdData, -} from '../../../../reducers/fiatOrders'; -import { CustomIdData } from '../../../../reducers/fiatOrders/types'; +} from '../../../../../reducers/fiatOrders'; +import { CustomIdData } from '../../../../../reducers/fiatOrders/types'; import { createNavigationDetails, useParams, -} from '../../../../util/navigation/navUtils'; -import { aggregatorOrderToFiatOrder } from '../orderProcessor/aggregator'; -import { createCustomOrderIdData } from '../orderProcessor/customOrderId'; -import ScreenLayout from '../components/ScreenLayout'; -import ErrorView from '../components/ErrorView'; -import ErrorViewWithReporting from '../components/ErrorViewWithReporting'; -import useAnalytics from '../hooks/useAnalytics'; -import { strings } from '../../../../../locales/i18n'; -import Routes from '../../../../constants/navigation/Routes'; +} from '../../../../../util/navigation/navUtils'; +import { aggregatorOrderToFiatOrder } from '../../common/orderProcessor/aggregator'; +import { createCustomOrderIdData } from '../../common/orderProcessor/customOrderId'; +import ScreenLayout from '../../common/components/ScreenLayout'; +import ErrorView from '../../common/components/ErrorView'; +import ErrorViewWithReporting from '../../common/components/ErrorViewWithReporting'; +import useAnalytics from '../../common/hooks/useAnalytics'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; import useHandleSuccessfulOrder from '../hooks/useHandleSuccessfulOrder'; interface CheckoutParams { @@ -35,12 +36,12 @@ interface CheckoutParams { } export const createCheckoutNavDetails = createNavigationDetails( - Routes.FIAT_ON_RAMP_AGGREGATOR.CHECKOUT, + Routes.RAMP.CHECKOUT, ); const CheckoutWebView = () => { - const { selectedAddress, selectedChainId, sdkError, callbackBaseUrl } = - useFiatOnRampSDK(); + const { selectedAddress, selectedChainId, sdkError, callbackBaseUrl, isBuy } = + useRampSDK(); const dispatch = useDispatch(); const trackEvent = useAnalytics(); const [error, setError] = useState(''); @@ -55,12 +56,20 @@ const CheckoutWebView = () => { const { url: uri, customOrderId, provider } = params; const handleCancelPress = useCallback(() => { - trackEvent('ONRAMP_CANCELED', { - location: 'Provider Webview', - chain_id_destination: selectedChainId, - provider_onramp: provider.name, - }); - }, [provider.name, selectedChainId, trackEvent]); + if (isBuy) { + trackEvent('ONRAMP_CANCELED', { + location: 'Provider Webview', + chain_id_destination: selectedChainId, + provider_onramp: provider.name, + }); + } else { + trackEvent('OFFRAMP_CANCELED', { + location: 'Provider Webview', + chain_id_source: selectedChainId, + provider_offramp: provider.name, + }); + } + }, [isBuy, provider.name, selectedChainId, trackEvent]); useEffect(() => { navigation.setOptions( @@ -81,10 +90,11 @@ const CheckoutWebView = () => { customOrderId, selectedChainId, selectedAddress, + isBuy ? OrderOrderTypeEnum.Buy : OrderOrderTypeEnum.Sell, ); setCustomIdData(customOrderIdData); dispatch(addFiatCustomIdData(customOrderIdData)); - }, [customOrderId, dispatch, selectedAddress, selectedChainId]); + }, [customOrderId, dispatch, isBuy, selectedAddress, selectedChainId]); const handleNavigationStateChange = async (navState: WebViewNavigation) => { if ( @@ -103,7 +113,10 @@ const CheckoutWebView = () => { return; } const orders = await SDK.orders(); - const order = await orders.getOrderFromCallback( + const getOrderFromCallbackMethod = isBuy + ? 'getOrderFromCallback' + : 'getSellOrderFromCallback'; + const order = await orders[getOrderFromCallbackMethod]( provider.id, navState?.url, selectedAddress, diff --git a/app/components/UI/Ramp/Views/GetStarted/GetStarted.styles.ts b/app/components/UI/Ramp/buy/Views/GetStarted/GetStarted.styles.ts similarity index 100% rename from app/components/UI/Ramp/Views/GetStarted/GetStarted.styles.ts rename to app/components/UI/Ramp/buy/Views/GetStarted/GetStarted.styles.ts diff --git a/app/components/UI/Ramp/Views/GetStarted/GetStarted.test.tsx b/app/components/UI/Ramp/buy/Views/GetStarted/GetStarted.test.tsx similarity index 52% rename from app/components/UI/Ramp/Views/GetStarted/GetStarted.test.tsx rename to app/components/UI/Ramp/buy/Views/GetStarted/GetStarted.test.tsx index ce5fe85f536..7e0f28577f9 100644 --- a/app/components/UI/Ramp/Views/GetStarted/GetStarted.test.tsx +++ b/app/components/UI/Ramp/buy/Views/GetStarted/GetStarted.test.tsx @@ -1,19 +1,19 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; -import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { renderScreen } from '../../../../../../util/test/renderWithProvider'; import GetStarted from './GetStarted'; -import { Region } from '../../types'; -import { OnRampSDK } from '../../sdk'; -import Routes from '../../../../../constants/navigation/Routes'; -import { createRegionsNavDetails } from '../Regions/Regions'; -import initialBackgroundState from '../../../../../util/test/initial-background-state.json'; +import { RampType, Region } from '../../../common/types'; +import { RampSDK } from '../../../common/sdk'; +import useRampNetwork from '../../../common/hooks/useRampNetwork'; +import Routes from '../../../../../../constants/navigation/Routes'; +import initialBackgroundState from '../../../../../../util/test/initial-background-state.json'; function render(Component: React.ComponentType) { return renderScreen( Component, { - name: Routes.FIAT_ON_RAMP_AGGREGATOR.GET_STARTED, + name: Routes.RAMP.GET_STARTED, }, { state: { @@ -25,16 +25,29 @@ function render(Component: React.ComponentType) { ); } -const mockuseFiatOnRampSDKInitialValues: Partial = { +const mockUseRampNetworkInitialValue: Partial< + ReturnType +> = [true]; + +let mockUseRampNetworkValue = [...mockUseRampNetworkInitialValue]; + +jest.mock('../../../common/hooks/useRampNetwork', () => + jest.fn(() => mockUseRampNetworkValue), +); + +const mockuseRampSDKInitialValues: Partial = { getStarted: false, setGetStarted: jest.fn(), sdkError: undefined, selectedChainId: '1', selectedRegion: null, + rampType: RampType.BUY, + isBuy: true, + isSell: false, }; -let mockUseFiatOnRampSDKValues: Partial = { - ...mockuseFiatOnRampSDKInitialValues, +let mockUseRampSDKValues: Partial = { + ...mockuseRampSDKInitialValues, }; const mockSetOptions = jest.fn(); @@ -60,12 +73,12 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../sdk', () => ({ - ...jest.requireActual('../../sdk'), - useFiatOnRampSDK: () => mockUseFiatOnRampSDKValues, +jest.mock('../../../common/sdk', () => ({ + ...jest.requireActual('../../../common/sdk'), + useRampSDK: () => mockUseRampSDKValues, })); -jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); +jest.mock('../../../common/hooks/useAnalytics', () => () => mockTrackEvent); describe('GetStarted', () => { afterEach(() => { @@ -74,20 +87,30 @@ describe('GetStarted', () => { mockReset.mockClear(); mockPop.mockClear(); mockTrackEvent.mockClear(); - (mockuseFiatOnRampSDKInitialValues.setGetStarted as jest.Mock).mockClear(); + (mockuseRampSDKInitialValues.setGetStarted as jest.Mock).mockClear(); }); - it('renders correctly', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + beforeEach(() => { + mockUseRampNetworkValue = [...mockUseRampNetworkInitialValue]; + mockUseRampSDKValues = { + ...mockuseRampSDKInitialValues, }; + }); + + it('renders correctly', async () => { + render(GetStarted); + expect(screen.toJSON()).toMatchSnapshot(); + + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; render(GetStarted); expect(screen.toJSON()).toMatchSnapshot(); }); it('renders correctly when sdkError is present', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockuseRampSDKInitialValues, sdkError: new Error('sdkError'), }; render(GetStarted); @@ -95,8 +118,8 @@ describe('GetStarted', () => { }); it('renders correctly when getStarted is true', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockuseRampSDKInitialValues, getStarted: true, }; render(GetStarted); @@ -108,20 +131,13 @@ describe('GetStarted', () => { expect(mockSetOptions).toBeCalledTimes(1); }); - it('navigates on get started button press', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, - }; + it('sets get started on button press', async () => { render(GetStarted); fireEvent.press(screen.getByRole('button', { name: 'Get started' })); - expect(mockNavigate).toHaveBeenCalledWith(...createRegionsNavDetails()); - expect(mockUseFiatOnRampSDKValues.setGetStarted).toHaveBeenCalledWith(true); + expect(mockUseRampSDKValues.setGetStarted).toHaveBeenCalledWith(true); }); it('navigates and tracks event on cancel button press', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, - }; render(GetStarted); fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); expect(mockPop).toHaveBeenCalled(); @@ -129,11 +145,38 @@ describe('GetStarted', () => { chain_id_destination: '1', location: 'Get Started Screen', }); + + mockTrackEvent.mockReset(); + mockUseRampSDKValues = { + ...mockUseRampSDKValues, + isBuy: false, + isSell: true, + rampType: RampType.SELL, + }; + render(GetStarted); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + chain_id_source: '1', + location: 'Get Started Screen', + }); + }); + + it('navigates to network switcher on unsupported network when getStarted is true', async () => { + mockUseRampSDKValues = { + ...mockuseRampSDKInitialValues, + getStarted: true, + }; + mockUseRampNetworkValue = [false]; + render(GetStarted); + expect(mockReset).toBeCalledWith({ + index: 0, + routes: [{ name: Routes.RAMP.NETWORK_SWITCHER }], + }); }); it('navigates to select region screen when getStarted is true and selectedRegion is null', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockuseRampSDKInitialValues, getStarted: true, selectedRegion: null, }; @@ -141,13 +184,13 @@ describe('GetStarted', () => { expect(mockReset).toBeCalledTimes(1); expect(mockReset).toBeCalledWith({ index: 0, - routes: [{ name: Routes.FIAT_ON_RAMP_AGGREGATOR.REGION_HAS_STARTED }], + routes: [{ name: Routes.RAMP.REGION_HAS_STARTED }], }); }); it('navigates to payment method when getStarted is true and selectedRegion is defined', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockuseRampSDKInitialValues, getStarted: true, selectedRegion: { id: 'us-al', @@ -159,7 +202,7 @@ describe('GetStarted', () => { index: 0, routes: [ { - name: Routes.FIAT_ON_RAMP_AGGREGATOR.PAYMENT_METHOD_HAS_STARTED, + name: Routes.RAMP.PAYMENT_METHOD_HAS_STARTED, params: { showBack: false, }, diff --git a/app/components/UI/Ramp/Views/GetStarted/GetStarted.tsx b/app/components/UI/Ramp/buy/Views/GetStarted/GetStarted.tsx similarity index 52% rename from app/components/UI/Ramp/Views/GetStarted/GetStarted.tsx rename to app/components/UI/Ramp/buy/Views/GetStarted/GetStarted.tsx index bb9d87751da..f37ce7dd86d 100644 --- a/app/components/UI/Ramp/Views/GetStarted/GetStarted.tsx +++ b/app/components/UI/Ramp/buy/Views/GetStarted/GetStarted.tsx @@ -1,41 +1,46 @@ import React, { useCallback, useEffect } from 'react'; import { Image, View, ScrollView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Text from '../../../../Base/Text'; -import StyledButton from '../../../StyledButton'; -import ScreenLayout from '../../components/ScreenLayout'; -import { getFiatOnRampAggNavbar } from '../../../Navbar'; -import { strings } from '../../../../../../locales/i18n'; -import { useTheme } from '../../../../../util/theme'; -import { useFiatOnRampSDK } from '../../sdk'; -import ErrorViewWithReporting from '../../components/ErrorViewWithReporting'; -import Routes from '../../../../../constants/navigation/Routes'; -import useAnalytics from '../../hooks/useAnalytics'; +import Text from '../../../../../Base/Text'; +import StyledButton from '../../../../StyledButton'; +import ScreenLayout from '../../../common/components/ScreenLayout'; +import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { strings } from '../../../../../../../locales/i18n'; +import { useTheme } from '../../../../../../util/theme'; +import { useRampSDK } from '../../../common/sdk'; +import ErrorViewWithReporting from '../../../common/components/ErrorViewWithReporting'; +import Routes from '../../../../../../constants/navigation/Routes'; +import useAnalytics from '../../../common/hooks/useAnalytics'; +import useRampNetwork from '../../../common/hooks/useRampNetwork'; import styles from './GetStarted.styles'; -import { createRegionsNavDetails } from '../Regions/Regions'; +import useRegions from '../../hooks/useRegions'; /* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ -const getStartedIcon = require('../../components/images/WalletInfo.png'); +const getStartedIcon = require('../../../common/components/images/WalletInfo.png'); const GetStarted: React.FC = () => { const navigation = useNavigation(); - const { - getStarted, - setGetStarted, - sdkError, - selectedChainId, - selectedRegion, - } = useFiatOnRampSDK(); + const { getStarted, setGetStarted, sdkError, selectedChainId, isBuy } = + useRampSDK(); + const { selectedRegion } = useRegions(); + const [isNetworkRampSupported] = useRampNetwork(); const trackEvent = useAnalytics(); const { colors } = useTheme(); const handleCancelPress = useCallback(() => { - trackEvent('ONRAMP_CANCELED', { - location: 'Get Started Screen', - chain_id_destination: selectedChainId, - }); - }, [selectedChainId, trackEvent]); + if (isBuy) { + trackEvent('ONRAMP_CANCELED', { + location: 'Get Started Screen', + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_CANCELED', { + location: 'Get Started Screen', + chain_id_source: selectedChainId, + }); + } + }, [isBuy, selectedChainId, trackEvent]); useEffect(() => { navigation.setOptions( @@ -52,18 +57,31 @@ const GetStarted: React.FC = () => { }, [navigation, colors, handleCancelPress]); const handleOnPress = useCallback(() => { - navigation.navigate(...createRegionsNavDetails()); + trackEvent( + isBuy ? 'ONRAMP_GET_STARTED_CLICKED' : 'OFFRAMP_GET_STARTED_CLICKED', + { + text: 'Get Started', + location: 'Get Started Screen', + }, + ); setGetStarted(true); - }, [navigation, setGetStarted]); + }, [isBuy, setGetStarted, trackEvent]); useEffect(() => { if (getStarted) { + if (!isNetworkRampSupported) { + navigation.reset({ + index: 0, + routes: [{ name: Routes.RAMP.NETWORK_SWITCHER }], + }); + return; + } if (selectedRegion) { navigation.reset({ index: 0, routes: [ { - name: Routes.FIAT_ON_RAMP_AGGREGATOR.PAYMENT_METHOD_HAS_STARTED, + name: Routes.RAMP.PAYMENT_METHOD_HAS_STARTED, params: { showBack: false }, }, ], @@ -71,11 +89,11 @@ const GetStarted: React.FC = () => { } else { navigation.reset({ index: 0, - routes: [{ name: Routes.FIAT_ON_RAMP_AGGREGATOR.REGION_HAS_STARTED }], + routes: [{ name: Routes.RAMP.REGION_HAS_STARTED }], }); } } - }, [getStarted, navigation, selectedRegion]); + }, [getStarted, isNetworkRampSupported, navigation, selectedRegion]); if (sdkError) { return ( @@ -106,12 +124,20 @@ const GetStarted: React.FC = () => { - {strings('fiat_on_ramp_aggregator.onboarding.quotes')} + {strings( + isBuy + ? 'fiat_on_ramp_aggregator.onboarding.quotes' + : 'fiat_on_ramp_aggregator.onboarding.quotes_sell', + )} - {strings('fiat_on_ramp_aggregator.onboarding.benefits')} + {strings( + isBuy + ? 'fiat_on_ramp_aggregator.onboarding.benefits' + : 'fiat_on_ramp_aggregator.onboarding.benefits_sell', + )} diff --git a/app/components/UI/Ramp/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap b/app/components/UI/Ramp/buy/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap similarity index 72% rename from app/components/UI/Ramp/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap rename to app/components/UI/Ramp/buy/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap index 4bd247746e5..a48a151cc51 100644 --- a/app/components/UI/Ramp/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap +++ b/app/components/UI/Ramp/buy/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap @@ -671,6 +671,677 @@ exports[`GetStarted renders correctly 1`] = ` `; +exports[`GetStarted renders correctly 2`] = ` + + + + + + + + + + + + + + + + + What to Expect + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Now you can cash out directly in MetaMask! Get up-to-the-minute quotes from trusted providers while we guide you every step of the way. + + + + + Off-ramping from crypto to your local currency is now easier than ever. + + + + + + + + + + Get started + + + + + + + + + + + + + + + + +`; + exports[`GetStarted renders correctly when getStarted is true 1`] = ` { const mockSetSelectedRegion = jest.fn(); const mockSetSelectedPaymentMethodId = jest.fn(); -const mockuseFiatOnRampSDKInitialValues: Partial = { +const mockUseRampSDKInitialValues: Partial = { setSelectedRegion: mockSetSelectedRegion, setSelectedPaymentMethodId: mockSetSelectedPaymentMethodId, selectedChainId: '1', sdkError: undefined, + rampType: RampType.BUY, + isBuy: true, + isSell: false, }; -let mockUseFiatOnRampSDKValues: Partial = { - ...mockuseFiatOnRampSDKInitialValues, +let mockUseRampSDKValues: Partial = { + ...mockUseRampSDKInitialValues, }; -jest.mock('../../sdk', () => ({ - ...jest.requireActual('../../sdk'), - useFiatOnRampSDK: () => mockUseFiatOnRampSDKValues, +jest.mock('../../../common/sdk', () => ({ + ...jest.requireActual('../../../common/sdk'), + useRampSDK: () => mockUseRampSDKValues, })); const mockQueryGetCountries = jest.fn(); @@ -129,12 +132,12 @@ let mockUseParamsValues: { showBack: undefined, }; -jest.mock('../../../../../util/navigation/navUtils', () => ({ - ...jest.requireActual('../../../../../util/navigation/navUtils'), +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), useParams: jest.fn(() => mockUseParamsValues), })); -jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); +jest.mock('../../../common/hooks/useAnalytics', () => () => mockTrackEvent); describe('PaymentMethods View', () => { afterEach(() => { @@ -147,8 +150,8 @@ describe('PaymentMethods View', () => { }); beforeEach(() => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, }; mockUseRegionsValues = { ...mockuseRegionsInitialValues, @@ -171,6 +174,17 @@ describe('PaymentMethods View', () => { expect(screen.toJSON()).toMatchSnapshot(); }); + it('renders correctly for sell', async () => { + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + isBuy: false, + isSell: true, + rampType: RampType.SELL, + }; + render(PaymentMethods); + expect(screen.toJSON()).toMatchSnapshot(); + }); + it('renders correctly with show back button false', async () => { mockUseParamsValues = { showBack: false, @@ -206,6 +220,21 @@ describe('PaymentMethods View', () => { expect(screen.toJSON()).toMatchSnapshot(); }); + it('renders correctly with empty data for sell', async () => { + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + isBuy: false, + isSell: true, + rampType: RampType.SELL, + }; + mockUsePaymentMethodsValues = { + ...mockUsePaymentMethodsInitialValues, + data: [], + }; + render(PaymentMethods); + expect(screen.toJSON()).toMatchSnapshot(); + }); + it('renders correctly with payment method with disclaimer', async () => { mockUsePaymentMethodsValues = { ...mockUsePaymentMethodsInitialValues, @@ -247,7 +276,7 @@ describe('PaymentMethods View', () => { render(PaymentMethods); fireEvent.press(screen.getByRole('button', { name: 'Reset Region' })); expect(mockReset).toBeCalledWith({ - routes: [{ name: Routes.FIAT_ON_RAMP_AGGREGATOR.REGION }], + routes: [{ name: Routes.RAMP.REGION }], }); }); @@ -259,6 +288,20 @@ describe('PaymentMethods View', () => { chain_id_destination: '1', location: 'Payment Method Screen', }); + + mockTrackEvent.mockReset(); + mockUseRampSDKValues = { + ...mockUseRampSDKValues, + isBuy: false, + isSell: true, + rampType: RampType.SELL, + }; + render(PaymentMethods); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + chain_id_source: '1', + location: 'Payment Method Screen', + }); }); it('selects payment method on press', async () => { @@ -267,30 +310,98 @@ describe('PaymentMethods View', () => { expect(mockSetSelectedPaymentMethodId).toBeCalledWith( '/payments/debit-credit-card', ); + + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "ONRAMP_PAYMENT_METHOD_SELECTED", + Object { + "available_payment_method_ids": Array [ + "/payments/instant-bank-transfer", + "/payments/apple-pay", + "/payments/debit-credit-card", + ], + "location": "Payment Method Screen", + "payment_method_id": "/payments/debit-credit-card", + "region": "/regions/cl", + }, + ] + `); + + mockTrackEvent.mockReset(); + mockUseRampSDKValues = { + ...mockUseRampSDKValues, + isBuy: false, + isSell: true, + rampType: RampType.SELL, + }; + render(PaymentMethods); + fireEvent.press(screen.getByRole('button', { name: 'Debit or Credit' })); + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "OFFRAMP_PAYMENT_METHOD_SELECTED", + Object { + "available_payment_method_ids": Array [ + "/payments/instant-bank-transfer", + "/payments/apple-pay", + "/payments/debit-credit-card", + ], + "location": "Payment Method Screen", + "payment_method_id": "/payments/debit-credit-card", + "region": "/regions/cl", + }, + ] + `); }); it('navigates to amount to buy on continue button press', async () => { render(PaymentMethods); fireEvent.press(screen.getByRole('button', { name: 'Continue to amount' })); - expect(mockNavigate).toHaveBeenCalledWith(...createAmountToBuyNavDetails()); - expect(mockTrackEvent).toHaveBeenCalledWith( - 'ONRAMP_CONTINUE_TO_AMOUNT_CLICKED', - { - available_payment_method_ids: [ - '/payments/instant-bank-transfer', - '/payments/apple-pay', - '/payments/debit-credit-card', - ], - payment_method_id: '/payments/instant-bank-transfer', - region: '/regions/cl', - location: 'Payment Method Screen', - }, - ); + expect(mockNavigate).toHaveBeenCalledWith(...createBuildQuoteNavDetails()); + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "ONRAMP_CONTINUE_TO_AMOUNT_CLICKED", + Object { + "available_payment_method_ids": Array [ + "/payments/instant-bank-transfer", + "/payments/apple-pay", + "/payments/debit-credit-card", + ], + "location": "Payment Method Screen", + "payment_method_id": "/payments/instant-bank-transfer", + "region": "/regions/cl", + }, + ] + `); + + mockTrackEvent.mockReset(); + mockUseRampSDKValues = { + ...mockUseRampSDKValues, + isBuy: false, + isSell: true, + rampType: RampType.SELL, + }; + render(PaymentMethods); + fireEvent.press(screen.getByRole('button', { name: 'Continue to amount' })); + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "OFFRAMP_CONTINUE_TO_AMOUNT_CLICKED", + Object { + "available_payment_method_ids": Array [ + "/payments/instant-bank-transfer", + "/payments/apple-pay", + "/payments/debit-credit-card", + ], + "location": "Payment Method Screen", + "payment_method_id": "/payments/instant-bank-transfer", + "region": "/regions/cl", + }, + ] + `); }); it('renders correctly with sdkError', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, sdkError: new Error('sdkError'), }; render(PaymentMethods); @@ -298,8 +409,8 @@ describe('PaymentMethods View', () => { }); it('navigates to home when clicking sdKError button', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, sdkError: new Error('sdkError'), }; render(PaymentMethods); diff --git a/app/components/UI/Ramp/Views/PaymentMethods/PaymentMethods.tsx b/app/components/UI/Ramp/buy/Views/PaymentMethods/PaymentMethods.tsx similarity index 65% rename from app/components/UI/Ramp/Views/PaymentMethods/PaymentMethods.tsx rename to app/components/UI/Ramp/buy/Views/PaymentMethods/PaymentMethods.tsx index 12f2b9a230f..50dacd99aed 100644 --- a/app/components/UI/Ramp/Views/PaymentMethods/PaymentMethods.tsx +++ b/app/components/UI/Ramp/buy/Views/PaymentMethods/PaymentMethods.tsx @@ -2,40 +2,38 @@ import React, { useCallback, useEffect } from 'react'; import { ScrollView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Text from '../../../../Base/Text'; -import Row from '../../components/Row'; -import ScreenLayout from '../../components/ScreenLayout'; -import PaymentMethod from '../../components/PaymentMethod'; -import SkeletonPaymentMethod from '../../components/SkeletonPaymentMethod'; -import ErrorView from '../../components/ErrorView'; -import ErrorViewWithReporting from '../../components/ErrorViewWithReporting'; -import StyledButton from '../../../StyledButton'; +import Text from '../../../../../Base/Text'; +import Row from '../../../common/components/Row'; +import ScreenLayout from '../../../common/components/ScreenLayout'; +import PaymentMethod from '../../../common/components/PaymentMethod'; +import SkeletonPaymentMethod from '../../../common/components/SkeletonPaymentMethod'; +import ErrorView from '../../../common/components/ErrorView'; +import ErrorViewWithReporting from '../../../common/components/ErrorViewWithReporting'; +import StyledButton from '../../../../StyledButton'; -import { useFiatOnRampSDK } from '../../sdk'; -import { useTheme } from '../../../../../util/theme'; -import { getFiatOnRampAggNavbar } from '../../../Navbar'; -import { strings } from '../../../../../../locales/i18n'; -import Routes from '../../../../../constants/navigation/Routes'; +import { useRampSDK } from '../../../common/sdk'; +import { useTheme } from '../../../../../../util/theme'; +import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { strings } from '../../../../../../../locales/i18n'; +import Routes from '../../../../../../constants/navigation/Routes'; -import useAnalytics from '../../hooks/useAnalytics'; +import useAnalytics from '../../../common/hooks/useAnalytics'; import usePaymentMethods from '../../hooks/usePaymentMethods'; import useRegions from '../../hooks/useRegions'; import { createNavigationDetails, useParams, -} from '../../../../../util/navigation/navUtils'; +} from '../../../../../../util/navigation/navUtils'; -import { createAmountToBuyNavDetails } from '../AmountToBuy'; +import { createBuildQuoteNavDetails } from '../BuildQuote/BuildQuote'; interface PaymentMethodsParams { showBack?: boolean; } export const createPaymentMethodsNavDetails = - createNavigationDetails( - Routes.FIAT_ON_RAMP_AGGREGATOR.PAYMENT_METHOD, - ); + createNavigationDetails(Routes.RAMP.PAYMENT_METHOD); const PaymentMethods = () => { const navigation = useNavigation(); @@ -44,11 +42,12 @@ const PaymentMethods = () => { const { showBack } = useParams(); const { + isBuy, setSelectedRegion, setSelectedPaymentMethodId, selectedChainId, sdkError, - } = useFiatOnRampSDK(); + } = useRampSDK(); const { selectedRegion } = useRegions(); @@ -61,25 +60,38 @@ const PaymentMethods = () => { } = usePaymentMethods(); const handleCancelPress = useCallback(() => { - trackEvent('ONRAMP_CANCELED', { - location: 'Payment Method Screen', - chain_id_destination: selectedChainId, - }); - }, [selectedChainId, trackEvent]); + if (isBuy) { + trackEvent('ONRAMP_CANCELED', { + location: 'Payment Method Screen', + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_CANCELED', { + location: 'Payment Method Screen', + chain_id_source: selectedChainId, + }); + } + }, [isBuy, selectedChainId, trackEvent]); const handlePaymentMethodPress = useCallback( (id) => { setSelectedPaymentMethodId(id); - trackEvent('ONRAMP_PAYMENT_METHOD_SELECTED', { - payment_method_id: id, - available_payment_method_ids: paymentMethods?.map( - (paymentMethod) => paymentMethod.id, - ) as string[], - region: selectedRegion?.id as string, - location: 'Payment Method Screen', - }); + trackEvent( + isBuy + ? 'ONRAMP_PAYMENT_METHOD_SELECTED' + : 'OFFRAMP_PAYMENT_METHOD_SELECTED', + { + payment_method_id: id, + available_payment_method_ids: paymentMethods?.map( + (paymentMethod) => paymentMethod.id, + ) as string[], + region: selectedRegion?.id as string, + location: 'Payment Method Screen', + }, + ); }, [ + isBuy, paymentMethods, selectedRegion?.id, setSelectedPaymentMethodId, @@ -93,7 +105,7 @@ const PaymentMethods = () => { const needsReset = showBack === false; if (needsReset) { navigation.reset({ - routes: [{ name: Routes.FIAT_ON_RAMP_AGGREGATOR.REGION }], + routes: [{ name: Routes.RAMP.REGION }], }); } else { navigation.goBack(); @@ -101,17 +113,23 @@ const PaymentMethods = () => { }, [showBack, setSelectedPaymentMethodId, setSelectedRegion, navigation]); const handleContinueToAmount = useCallback(() => { - trackEvent('ONRAMP_CONTINUE_TO_AMOUNT_CLICKED', { - available_payment_method_ids: paymentMethods?.map( - (paymentMethod) => paymentMethod.id, - ) as string[], - payment_method_id: currentPaymentMethod?.id as string, - region: selectedRegion?.id as string, - location: 'Payment Method Screen', - }); - navigation.navigate(...createAmountToBuyNavDetails()); + trackEvent( + isBuy + ? 'ONRAMP_CONTINUE_TO_AMOUNT_CLICKED' + : 'OFFRAMP_CONTINUE_TO_AMOUNT_CLICKED', + { + available_payment_method_ids: paymentMethods?.map( + (paymentMethod) => paymentMethod.id, + ) as string[], + payment_method_id: currentPaymentMethod?.id as string, + region: selectedRegion?.id as string, + location: 'Payment Method Screen', + }, + ); + navigation.navigate(...createBuildQuoteNavDetails()); }, [ currentPaymentMethod?.id, + isBuy, navigation, paymentMethods, selectedRegion?.id, @@ -124,7 +142,9 @@ const PaymentMethods = () => { navigation, { title: strings( - 'fiat_on_ramp_aggregator.payment_method.payment_method', + isBuy + ? 'fiat_on_ramp_aggregator.payment_method.payment_method' + : 'fiat_on_ramp_aggregator.payment_method.cash_destination', ), showBack, }, @@ -132,7 +152,7 @@ const PaymentMethods = () => { handleCancelPress, ), ); - }, [navigation, colors, handleCancelPress, showBack]); + }, [navigation, colors, handleCancelPress, showBack, isBuy]); if (sdkError) { return ( @@ -187,11 +207,15 @@ const PaymentMethods = () => { { ? undefined : () => handlePaymentMethodPress(payment.id) } + isBuy={isBuy} /> ))} diff --git a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap b/app/components/UI/Ramp/buy/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap similarity index 80% rename from app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap rename to app/components/UI/Ramp/buy/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap index 38a68a39856..a4ec3490ad5 100644 --- a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap +++ b/app/components/UI/Ramp/buy/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap @@ -2060,7 +2060,7 @@ exports[`PaymentMethods View renders correctly 1`] = ` `; -exports[`PaymentMethods View renders correctly while loading 1`] = ` +exports[`PaymentMethods View renders correctly for sell 1`] = ` - Payment Method + Cash Destination - - - + + - - - - - @@ -2658,246 +2584,470 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` style={ Array [ Object { - "backgroundColor": "#F2F4F6", - "borderRadius": 30, - "padding": 14, - }, - Object { - "padding": 8, - }, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - Object { - "paddingRight": 100, + "alignItems": "center", + "flexDirection": "row", + "marginRight": 15, }, undefined, ] } - /> - - - - + > + + +  + + + - + > + + + Instant Bank Transfer + + + + + + + + + + + + + - - - - - - - + Object { + "fontFamily": "Feather", + "fontStyle": "normal", + "fontWeight": "normal", + }, + Object {}, + ] + } + > +  + + + Instant + • + + + $ + + + $ + + + $ + + + highest sell limit + + + - - - - - @@ -2905,425 +3055,272 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` style={ Array [ Object { - "backgroundColor": "#F2F4F6", - "borderRadius": 6, - "padding": 18, + "alignItems": "center", + "flexDirection": "row", }, undefined, ] } - /> - - - - - - - - - + > + + +  + + + - - - - - - - - - - - - - - - - - - - + > + + + Apple Pay + + + - - - + > + + + + + + + + + - +  + + + Instant + • + + - - + $ + + + > + $ + + + $ + + + lowest sell limit - - + + + + + + + + +  + + + + + + + Debit or Credit + + + + + + + + + + + + + + + +  + + + 5 - 10 mins + • + + + $ + + + $ + + + $ + + + lowest sell limit + + + + + + + + + + + + + + Continue to amount + + + + + + + + + + + + + + + + + +`; + +exports[`PaymentMethods View renders correctly while loading 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Payment Method + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`PaymentMethods View renders correctly with empty data 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Payment Method + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + No Payment Methods in Chile + + + + + There are currently no supported payment methods in your region. Please check by soon; we are frequently expanding support to new regions. + +If you selected Chile by mistake, click the button below to reset your region. + + + + - + - - - - + ], + null, + ] + } + > + Reset Region + + + @@ -3491,7 +6258,7 @@ exports[`PaymentMethods View renders correctly while loading 1`] = ` `; -exports[`PaymentMethods View renders correctly with empty data 1`] = ` +exports[`PaymentMethods View renders correctly with empty data for sell 1`] = ` - Payment Method + Cash Destination - No Payment Methods in Chile + No Cash Destinations in Chile - There are currently no supported payment methods in your region. Please check by soon; we are frequently expanding support to new regions. + There are currently no supported cash destinations in your region. Please check by soon; we are frequently expanding support to new regions. If you selected Chile by mistake, click the button below to reset your region. diff --git a/app/components/UI/Ramp/Views/PaymentMethods/index.ts b/app/components/UI/Ramp/buy/Views/PaymentMethods/index.ts similarity index 100% rename from app/components/UI/Ramp/Views/PaymentMethods/index.ts rename to app/components/UI/Ramp/buy/Views/PaymentMethods/index.ts diff --git a/app/components/UI/Ramp/Views/Quotes/LoadingQuotes.tsx b/app/components/UI/Ramp/buy/Views/Quotes/LoadingQuotes.tsx similarity index 71% rename from app/components/UI/Ramp/Views/Quotes/LoadingQuotes.tsx rename to app/components/UI/Ramp/buy/Views/Quotes/LoadingQuotes.tsx index d053ff7a5f9..f061d204478 100644 --- a/app/components/UI/Ramp/Views/Quotes/LoadingQuotes.tsx +++ b/app/components/UI/Ramp/buy/Views/Quotes/LoadingQuotes.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Row from '../../components/Row'; -import SkeletonQuote from '../../components/SkeletonQuote'; +import Row from '../../../common/components/Row'; +import SkeletonQuote from '../../../common/components/SkeletonQuote'; function LoadingQuotes() { return ( diff --git a/app/components/UI/Ramp/Views/Quotes/Quotes.constants.ts b/app/components/UI/Ramp/buy/Views/Quotes/Quotes.constants.ts similarity index 100% rename from app/components/UI/Ramp/Views/Quotes/Quotes.constants.ts rename to app/components/UI/Ramp/buy/Views/Quotes/Quotes.constants.ts diff --git a/app/components/UI/Ramp/Views/Quotes/Quotes.styles.ts b/app/components/UI/Ramp/buy/Views/Quotes/Quotes.styles.ts similarity index 93% rename from app/components/UI/Ramp/Views/Quotes/Quotes.styles.ts rename to app/components/UI/Ramp/buy/Views/Quotes/Quotes.styles.ts index 40315434da9..9d12db66c89 100644 --- a/app/components/UI/Ramp/Views/Quotes/Quotes.styles.ts +++ b/app/components/UI/Ramp/buy/Views/Quotes/Quotes.styles.ts @@ -1,5 +1,5 @@ import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../util/theme/models'; +import { Theme } from '../../../../../../util/theme/models'; const styleSheet = (params: { theme: Theme }) => { const { theme } = params; diff --git a/app/components/UI/Ramp/Views/Quotes/Quotes.test.tsx b/app/components/UI/Ramp/buy/Views/Quotes/Quotes.test.tsx similarity index 66% rename from app/components/UI/Ramp/Views/Quotes/Quotes.test.tsx rename to app/components/UI/Ramp/buy/Views/Quotes/Quotes.test.tsx index ab3dcf76cca..61f8b17b29f 100644 --- a/app/components/UI/Ramp/Views/Quotes/Quotes.test.tsx +++ b/app/components/UI/Ramp/buy/Views/Quotes/Quotes.test.tsx @@ -11,7 +11,7 @@ import { screen, render as renderComponent, } from '@testing-library/react-native'; -import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { renderScreen } from '../../../../../../util/test/renderWithProvider'; import Quotes, { QuotesParams } from './Quotes'; import { mockQuotesData } from './Quotes.constants'; @@ -19,17 +19,18 @@ import type { DeepPartial } from './Quotes.types'; import Timer from './Timer'; import LoadingQuotes from './LoadingQuotes'; -import { OnRampSDK } from '../../sdk'; +import { RampSDK } from '../../../common/sdk'; import useQuotes from '../../hooks/useQuotes'; -import Routes from '../../../../../constants/navigation/Routes'; -import initialBackgroundState from '../../../../../util/test/initial-background-state.json'; +import Routes from '../../../../../../constants/navigation/Routes'; +import initialBackgroundState from '../../../../../../util/test/initial-background-state.json'; +import { RampType } from '../../../common/types'; function render(Component: React.ComponentType) { return renderScreen( Component, { - name: Routes.FIAT_ON_RAMP_AGGREGATOR.QUOTES, + name: Routes.RAMP.QUOTES, }, { state: { @@ -69,7 +70,7 @@ jest.mock('@react-navigation/native', () => { }; }); -const mockuseFiatOnRampSDKInitialValues: Partial = { +const mockUseRampSDKInitialValues: Partial = { selectedPaymentMethodId: '/payment-methods/test-payment-method', selectedChainId: '1', appConfig: { @@ -79,21 +80,24 @@ const mockuseFiatOnRampSDKInitialValues: Partial = { }, callbackBaseUrl: '', sdkError: undefined, + rampType: RampType.BUY, + isBuy: true, + isSell: false, }; -let mockUseFiatOnRampSDKValues: DeepPartial = { - ...mockuseFiatOnRampSDKInitialValues, +let mockUseRampSDKValues: DeepPartial = { + ...mockUseRampSDKInitialValues, }; -jest.mock('../../sdk', () => ({ - ...jest.requireActual('../../sdk'), - useFiatOnRampSDK: () => mockUseFiatOnRampSDKValues, +jest.mock('../../../common/sdk', () => ({ + ...jest.requireActual('../../../common/sdk'), + useRampSDK: () => mockUseRampSDKValues, })); -jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); +jest.mock('../../../common/hooks/useAnalytics', () => () => mockTrackEvent); jest.mock('../../hooks/useInAppBrowser', () => () => mockRenderInAppBrowser); -const mockuseParamsInitialValues: DeepPartial = { +const mockUseParamsInitialValues: DeepPartial = { amount: 50, asset: { symbol: 'ETH', @@ -104,28 +108,28 @@ const mockuseParamsInitialValues: DeepPartial = { }; let mockUseParamsValues = { - ...mockuseParamsInitialValues, + ...mockUseParamsInitialValues, }; -jest.mock('../../../../../util/navigation/navUtils', () => ({ - ...jest.requireActual('../../../../../util/navigation/navUtils'), +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), useParams: () => mockUseParamsValues, })); const mockQueryGetQuotes = jest.fn(); -const mockuseQuotesInitialValues: Partial> = { +const mockUseQuotesInitialValues: Partial> = { data: mockQuotesData as (QuoteResponse | QuoteError)[], isFetching: false, error: null, query: mockQueryGetQuotes, }; -let mockuseQuotesValues: Partial> = { - ...mockuseQuotesInitialValues, +let mockUseQuotesValues: Partial> = { + ...mockUseQuotesInitialValues, }; -jest.mock('../../hooks/useQuotes', () => jest.fn(() => mockuseQuotesValues)); +jest.mock('../../hooks/useQuotes', () => jest.fn(() => mockUseQuotesValues)); describe('Quotes', () => { afterEach(() => { @@ -138,20 +142,20 @@ describe('Quotes', () => { // to have control over the timer with jest timer methods // Reference: https://jestjs.io/docs/timer-mocks jest.useFakeTimers(); - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, }; mockUseParamsValues = { - ...mockuseParamsInitialValues, + ...mockUseParamsInitialValues, }; - mockuseQuotesValues = { - ...mockuseQuotesInitialValues, + mockUseQuotesValues = { + ...mockUseQuotesInitialValues, }; }); it('calls setOptions when rendering', async () => { - mockuseQuotesValues = { - ...mockuseQuotesInitialValues, + mockUseQuotesValues = { + ...mockUseQuotesInitialValues, isFetching: true, data: null, }; @@ -172,11 +176,26 @@ describe('Quotes', () => { jest.useRealTimers(); }); }); + it('navigates and tracks event on SELL cancel button press', async () => { + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; + render(Quotes); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + chain_id_source: '1', + location: 'Quotes Screen', + results_count: mockQuotesData.filter((quote) => !quote.error).length, + }); + act(() => { + jest.useRealTimers(); + }); + }); it('renders animation on first fetching', async () => { jest.useRealTimers(); - mockuseQuotesValues = { - ...mockuseQuotesInitialValues, + mockUseQuotesValues = { + ...mockUseQuotesInitialValues, isFetching: true, data: null, }; @@ -187,8 +206,8 @@ describe('Quotes', () => { }); it('renders correctly after animation without quotes', async () => { - mockuseQuotesValues = { - ...mockuseQuotesInitialValues, + mockUseQuotesValues = { + ...mockUseQuotesInitialValues, data: [], }; render(Quotes); @@ -215,23 +234,28 @@ describe('Quotes', () => { }); }); - it('navigates and tracks events when pressing buy button with app browser quote', async () => { + const simulateQuoteSelection = async ( + browser: ProviderBuyFeatureBrowserEnum, + ) => { // Mock the functions for the 2nd mocked quote const mockData = cloneDeep(mockQuotesData); const mockedQuote = mockData[1] as QuoteResponse; const mockQuoteProviderName = mockedQuote.provider?.name as string; - mockedQuote.buy = () => - Promise.resolve({ - browser: ProviderBuyFeatureBrowserEnum.AppBrowser, - createWidget: () => - Promise.resolve({ - url: 'https://test-url.on-ramp.metamask', - orderId: 'test-order-id', - browser: ProviderBuyFeatureBrowserEnum.AppBrowser, - }), - }); - mockuseQuotesValues = { - ...mockuseQuotesInitialValues, + + const mockedBuyAction = { + browser, + createWidget: () => + Promise.resolve({ + url: 'https://test-url.on-ramp.metamask', + orderId: 'test-order-id', + browser, + }), + }; + + mockedQuote.buy = () => Promise.resolve(mockedBuyAction); + + mockUseQuotesValues = { + ...mockUseQuotesInitialValues, data: mockData as (QuoteResponse | QuoteError)[], }; render(Quotes); @@ -252,16 +276,19 @@ describe('Quotes', () => { fireEvent.press(quoteContinueButton); }); - expect(mockNavigate).toBeCalledTimes(1); - expect(mockNavigate).toBeCalledWith( - Routes.FIAT_ON_RAMP_AGGREGATOR.CHECKOUT, - { - provider: mockedQuote.provider, - customOrderId: 'test-order-id', - url: 'https://test-url.on-ramp.metamask', - }, - ); + return { mockedQuote, mockedBuyAction }; + }; + it('navigates and tracks events when pressing buy button with app browser quote', async () => { + const { mockedQuote } = await simulateQuoteSelection( + ProviderBuyFeatureBrowserEnum.AppBrowser, + ); + expect(mockNavigate).toBeCalledTimes(1); + expect(mockNavigate).toBeCalledWith(Routes.RAMP.CHECKOUT, { + provider: mockedQuote.provider, + customOrderId: 'test-order-id', + url: 'https://test-url.on-ramp.metamask', + }); expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` Array [ "ONRAMP_PROVIDER_SELECTED", @@ -284,43 +311,39 @@ describe('Quotes', () => { `); }); - it('calls renderInAppBrowser hook and tracks events when pressing buy button with in-app browser quote', async () => { - // Mock the functions for the 2nd mocked quote - const mockData = cloneDeep(mockQuotesData); - const mockedQuote = mockData[1] as QuoteResponse; - const mockQuoteProviderName = mockedQuote.provider?.name as string; - const mockedBuyAction = { - browser: ProviderBuyFeatureBrowserEnum.InAppOsBrowser, - createWidget: () => - Promise.resolve({ - url: 'https://test-url.on-ramp.metamask', - orderId: 'test-order-id', - browser: ProviderBuyFeatureBrowserEnum.InAppOsBrowser, - }), - }; - mockedQuote.buy = () => Promise.resolve(mockedBuyAction); - mockuseQuotesValues = { - ...mockuseQuotesInitialValues, - data: mockData as (QuoteResponse | QuoteError)[], - }; - - render(Quotes); - act(() => { - jest.advanceTimersByTime(3000); - jest.clearAllTimers(); - jest.useRealTimers(); - }); + it('calls the correct analytics event when a sell provider is clicked', async () => { + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; - const quoteToSelect = screen.getByLabelText(mockQuoteProviderName); - fireEvent.press(quoteToSelect); + await simulateQuoteSelection(ProviderBuyFeatureBrowserEnum.AppBrowser); - const quoteContinueButton = screen.getByRole('button', { - name: `Continue with ${mockQuoteProviderName}`, - }); + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "OFFRAMP_PROVIDER_SELECTED", + Object { + "chain_id_source": "1", + "currency_destination": "USD", + "currency_source": "ETH", + "exchange_rate": 2809.8765432098767, + "fiat_out": 0.0162, + "gas_fee": 2.64, + "payment_method_id": "/payment-methods/test-payment-method", + "processing_fee": 1.8399999999999999, + "provider_offramp": "MoonPay (Staging)", + "quote_position": 2, + "refresh_count": 1, + "results_count": 2, + "total_fee": 4.48, + }, + ] + `); + }); - await act(async () => { - fireEvent.press(quoteContinueButton); - }); + it('calls renderInAppBrowser hook and tracks events when pressing buy button with in-app browser quote', async () => { + const { mockedQuote, mockedBuyAction } = await simulateQuoteSelection( + ProviderBuyFeatureBrowserEnum.InAppOsBrowser, + ); expect(mockRenderInAppBrowser).toBeCalledWith( mockedBuyAction, @@ -351,6 +374,35 @@ describe('Quotes', () => { `); }); + it('calls the correct analytics event for in-app browser sell quotes', async () => { + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; + + await simulateQuoteSelection(ProviderBuyFeatureBrowserEnum.InAppOsBrowser); + + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "OFFRAMP_PROVIDER_SELECTED", + Object { + "chain_id_source": "1", + "currency_destination": "USD", + "currency_source": "ETH", + "exchange_rate": 2809.8765432098767, + "fiat_out": 0.0162, + "gas_fee": 2.64, + "payment_method_id": "/payment-methods/test-payment-method", + "processing_fee": 1.8399999999999999, + "provider_offramp": "MoonPay (Staging)", + "quote_position": 2, + "refresh_count": 1, + "results_count": 2, + "total_fee": 4.48, + }, + ] + `); + }); + it('renders information when pressing quote provider logo', async () => { render(Quotes); act(() => { @@ -405,10 +457,10 @@ describe('Quotes', () => { }); it('renders quotes expired screen', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, appConfig: { - ...mockuseFiatOnRampSDKInitialValues.appConfig, + ...mockUseRampSDKInitialValues.appConfig, POLLING_CYCLES: 0, }, }; @@ -472,9 +524,62 @@ describe('Quotes', () => { }); }); + it('calls track event on sell quotes received and sell quote error', async () => { + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; + render(Quotes); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + expect(mockTrackEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "OFFRAMP_QUOTES_RECEIVED", + Object { + "amount": 50, + "average_fiat_out": 0.016671, + "average_gas_fee": 1.32, + "average_processing_fee": 1.455, + "average_total_fee": 2.7750000000000004, + "average_total_fee_of_amount": 202.50619012432108, + "chain_id_source": "1", + "currency_destination": "USD", + "currency_source": "ETH", + "payment_method_id": "/payment-methods/test-payment-method", + "provider_offramp_first": "Banxa (Staging)", + "provider_offramp_last": "MoonPay (Staging)", + "provider_offramp_list": Array [ + "Banxa (Staging)", + "MoonPay (Staging)", + ], + "refresh_count": 1, + "results_count": 2, + }, + ], + Array [ + "OFFRAMP_QUOTE_ERROR", + Object { + "amount": 50, + "chain_id_source": "1", + "currency_destination": "USD", + "currency_source": "ETH", + "error_message": undefined, + "payment_method_id": "/payment-methods/test-payment-method", + "provider_offramp": "Transak (Staging)", + }, + ], + ] + `); + act(() => { + jest.useRealTimers(); + }); + }); + it('renders correctly with sdkError', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, sdkError: new Error('Example SDK Error'), }; render(Quotes); @@ -486,8 +591,8 @@ describe('Quotes', () => { }); it('navigates to home when clicking sdKError button', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, sdkError: new Error('Example SDK Error'), }; render(Quotes); @@ -501,8 +606,8 @@ describe('Quotes', () => { }); it('renders correctly when fetching quotes errors', async () => { - mockuseQuotesValues = { - ...mockuseQuotesInitialValues, + mockUseQuotesValues = { + ...mockUseQuotesInitialValues, error: 'Test Error', }; render(Quotes); @@ -513,8 +618,8 @@ describe('Quotes', () => { }); it('fetches quotes again when pressing button after fetching quotes errors', async () => { - mockuseQuotesValues = { - ...mockuseQuotesInitialValues, + mockUseQuotesValues = { + ...mockUseQuotesInitialValues, error: 'Test Error', }; render(Quotes); diff --git a/app/components/UI/Ramp/Views/Quotes/Quotes.tsx b/app/components/UI/Ramp/buy/Views/Quotes/Quotes.tsx similarity index 65% rename from app/components/UI/Ramp/Views/Quotes/Quotes.tsx rename to app/components/UI/Ramp/buy/Views/Quotes/Quotes.tsx index 3c2dc06cf89..17db0b17619 100644 --- a/app/components/UI/Ramp/Views/Quotes/Quotes.tsx +++ b/app/components/UI/Ramp/buy/Views/Quotes/Quotes.tsx @@ -6,32 +6,33 @@ import { ProviderBuyFeatureBrowserEnum, QuoteError, QuoteResponse, + SellQuoteResponse, } from '@consensys/on-ramp-sdk'; import { Provider } from '@consensys/on-ramp-sdk/dist/API'; import styleSheet from './Quotes.styles'; import LoadingQuotes from './LoadingQuotes'; -import Text from '../../../../Base/Text'; -import ScreenLayout from '../../components/ScreenLayout'; -import ErrorViewWithReporting from '../../components/ErrorViewWithReporting'; -import ErrorView from '../../components/ErrorView'; -import Row from '../../components/Row'; -import Quote from '../../components/Quote'; -import InfoAlert from '../../components/InfoAlert'; -import { getFiatOnRampAggNavbar } from '../../../Navbar'; - -import useAnalytics from '../../hooks/useAnalytics'; +import Text from '../../../../../Base/Text'; +import ScreenLayout from '../../../common/components/ScreenLayout'; +import ErrorViewWithReporting from '../../../common/components/ErrorViewWithReporting'; +import ErrorView from '../../../common/components/ErrorView'; +import Row from '../../../common/components/Row'; +import Quote from '../../../common/components/Quote'; +import InfoAlert from '../../../common/components/InfoAlert'; +import { getFiatOnRampAggNavbar } from '../../../../Navbar'; + +import useAnalytics from '../../../common/hooks/useAnalytics'; import useQuotes from '../../hooks/useQuotes'; -import { useFiatOnRampSDK } from '../../sdk'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useRampSDK } from '../../../common/sdk'; +import { useStyles } from '../../../../../../component-library/hooks'; import { createNavigationDetails, useParams, -} from '../../../../../util/navigation/navUtils'; -import Routes from '../../../../../constants/navigation/Routes'; -import { strings } from '../../../../../../locales/i18n'; -import LoadingAnimation from '../../components/LoadingAnimation'; -import useInterval from '../../../../hooks/useInterval'; +} from '../../../../../../util/navigation/navUtils'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../../locales/i18n'; +import LoadingAnimation from '../../../common/components/LoadingAnimation'; +import useInterval from '../../../../../hooks/useInterval'; import Animated, { Extrapolate, interpolate, @@ -41,9 +42,10 @@ import Animated, { } from 'react-native-reanimated'; import useInAppBrowser from '../../hooks/useInAppBrowser'; import { createCheckoutNavDetails } from '../Checkout'; -import { PROVIDER_LINKS } from '../../types'; -import Logger from '../../../../../util/Logger'; +import { PROVIDER_LINKS, ScreenLocation } from '../../../common/types'; +import Logger from '../../../../../../util/Logger'; import Timer from './Timer'; +import { isBuyQuote, isBuyQuotes, isSellQuotes } from '../../../common/utils'; export interface QuotesParams { amount: number; @@ -52,7 +54,7 @@ export interface QuotesParams { } export const createQuotesNavDetails = createNavigationDetails( - Routes.FIAT_ON_RAMP_AGGREGATOR.QUOTES, + Routes.RAMP.QUOTES, ); function Quotes() { @@ -65,7 +67,9 @@ function Quotes() { appConfig, callbackBaseUrl, sdkError, - } = useFiatOnRampSDK(); + rampType, + isBuy, + } = useRampSDK(); const renderInAppBrowser = useInAppBrowser(); const [isLoading, setIsLoading] = useState(true); @@ -107,18 +111,34 @@ function Quotes() { query: fetchQuotes, } = useQuotes(params.amount); - const filteredQuotes = useMemo( - () => quotes?.filter((quote): quote is QuoteResponse => !quote.error) ?? [], - [quotes], - ); + const filteredQuotes = useMemo(() => { + if (quotes) { + if (isBuyQuotes(quotes, rampType)) { + return quotes.filter((quote): quote is QuoteResponse => !quote.error); + } else if (isSellQuotes(quotes, rampType)) { + return quotes.filter( + (quote): quote is SellQuoteResponse => !quote.error, + ); + } + } + return []; + }, [quotes, rampType]); const handleCancelPress = useCallback(() => { - trackEvent('ONRAMP_CANCELED', { - location: 'Quotes Screen', - chain_id_destination: selectedChainId, - results_count: filteredQuotes.length, - }); - }, [filteredQuotes.length, selectedChainId, trackEvent]); + if (isBuy) { + trackEvent('ONRAMP_CANCELED', { + location: 'Quotes Screen', + chain_id_destination: selectedChainId, + results_count: filteredQuotes.length, + }); + } else { + trackEvent('OFFRAMP_CANCELED', { + location: 'Quotes Screen', + chain_id_source: selectedChainId, + results_count: filteredQuotes.length, + }); + } + }, [filteredQuotes.length, isBuy, selectedChainId, trackEvent]); const handleFetchQuotes = useCallback(() => { setIsLoading(true); @@ -126,46 +146,68 @@ function Quotes() { setPollingCyclesLeft(appConfig.POLLING_CYCLES - 1); setRemainingTime(appConfig.POLLING_INTERVAL); fetchQuotes(); - trackEvent('ONRAMP_QUOTES_REQUESTED', { - currency_source: params.fiatCurrency?.symbol, - currency_destination: params.asset?.symbol, + + const payload = { payment_method_id: selectedPaymentMethodId as string, - chain_id_destination: selectedChainId, amount: params.amount, - location: 'Quotes Screen', - }); + location: 'Quotes Screen' as ScreenLocation, + }; + + if (isBuy) { + trackEvent('ONRAMP_QUOTES_REQUESTED', { + ...payload, + currency_source: params.fiatCurrency?.symbol, + currency_destination: params.asset?.symbol, + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_QUOTES_REQUESTED', { + ...payload, + currency_destination: params.fiatCurrency?.symbol, + currency_source: params.asset?.symbol, + chain_id_source: selectedChainId, + }); + } }, [ appConfig.POLLING_CYCLES, appConfig.POLLING_INTERVAL, fetchQuotes, + isBuy, params, selectedChainId, selectedPaymentMethodId, trackEvent, ]); - const handleOnQuotePress = useCallback((quote: QuoteResponse) => { - setProviderId(quote.provider.id); - }, []); + const handleOnQuotePress = useCallback( + (quote: QuoteResponse | SellQuoteResponse) => { + setProviderId(quote.provider.id); + }, + [], + ); const handleInfoPress = useCallback( (quote) => { if (quote?.provider) { setSelectedProviderInfo(quote.provider); setShowProviderInfo(true); - trackEvent('ONRAMP_PROVIDER_DETAILS_VIEWED', { - provider_onramp: quote.provider.name, - }); + + if (isBuy) { + trackEvent('ONRAMP_PROVIDER_DETAILS_VIEWED', { + provider_onramp: quote.provider.name, + }); + } else { + trackEvent('OFFRAMP_PROVIDER_DETAILS_VIEWED', { + provider_offramp: quote.provider.name, + }); + } } }, - [trackEvent], + [isBuy, trackEvent], ); - const handleOnPressBuy = useCallback( - async (quote: QuoteResponse, index) => { - if (!quote?.buy) { - return; - } + const handleOnPressCTA = useCallback( + async (quote: QuoteResponse | SellQuoteResponse, index) => { try { setIsQuoteLoading(true); @@ -174,24 +216,45 @@ function Quotes() { (quote.providerFee ?? 0) + (quote.extraFee ?? 0); - trackEvent('ONRAMP_PROVIDER_SELECTED', { - provider_onramp: quote.provider.name, + const payload = { refresh_count: appConfig.POLLING_CYCLES - pollingCyclesLeft, quote_position: index + 1, results_count: filteredQuotes.length, - crypto_out: quote.amountOut ?? 0, - currency_source: params.fiatCurrency?.symbol, - currency_destination: params.asset?.symbol, - chain_id_destination: selectedChainId, payment_method_id: selectedPaymentMethodId as string, total_fee: totalFee, gas_fee: quote.networkFee ?? 0, processing_fee: quote.providerFee ?? 0, exchange_rate: ((quote.amountIn ?? 0) - totalFee) / (quote.amountOut ?? 0), - }); + }; + + if (isBuy) { + trackEvent('ONRAMP_PROVIDER_SELECTED', { + ...payload, + currency_source: params.fiatCurrency?.symbol, + currency_destination: params.asset?.symbol, + provider_onramp: quote.provider.name, + crypto_out: quote.amountOut ?? 0, + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_PROVIDER_SELECTED', { + ...payload, + currency_destination: params.fiatCurrency?.symbol, + currency_source: params.asset?.symbol, + provider_offramp: quote.provider.name, + fiat_out: quote.amountOut ?? 0, + chain_id_source: selectedChainId, + }); + } + + let buyAction; + if (isBuyQuote(quote, rampType)) { + buyAction = await quote.buy(); + } else { + buyAction = await quote.sell(); + } - const buyAction = await quote.buy(); if ( buyAction.browser === ProviderBuyFeatureBrowserEnum.InAppOsBrowser ) { @@ -226,12 +289,14 @@ function Quotes() { } }, [ + isBuy, appConfig.POLLING_CYCLES, callbackBaseUrl, filteredQuotes.length, navigation, params, pollingCyclesLeft, + rampType, renderInAppBrowser, selectedChainId, selectedPaymentMethodId, @@ -306,9 +371,10 @@ function Quotes() { useEffect(() => { if (quotes && !isFetchingQuotes && pollingCyclesLeft >= 0) { - const quotesWithoutError = quotes.filter( - (quote): quote is QuoteResponse => !quote.error, - ); + const quotesWithoutError = filteredQuotes as ( + | QuoteResponse + | SellQuoteResponse + )[]; if (quotesWithoutError.length > 0) { const totals = quotesWithoutError.reduce( (acc, curr) => { @@ -335,54 +401,91 @@ function Quotes() { feeAmountRatio: 0, }, ); - trackEvent('ONRAMP_QUOTES_RECEIVED', { - currency_source: params.fiatCurrency?.symbol, - currency_destination: params.asset?.symbol, - chain_id_destination: selectedChainId, + + const payload = { amount: params.amount, payment_method_id: selectedPaymentMethodId as string, refresh_count: appConfig.POLLING_CYCLES - pollingCyclesLeft, results_count: quotesWithoutError.length, - average_crypto_out: totals.amountOut / quotesWithoutError.length, average_total_fee: totals.totalFee / quotesWithoutError.length, average_gas_fee: totals.totalGasFee / quotesWithoutError.length, average_processing_fee: totals.totalProcessingFee / quotesWithoutError.length, - provider_onramp_list: quotesWithoutError.map( - ({ provider }) => provider.name, - ), - provider_onramp_first: quotesWithoutError[0]?.provider?.name, average_total_fee_of_amount: totals.feeAmountRatio / quotesWithoutError.length, - provider_onramp_last: - quotesWithoutError.length > 1 - ? quotesWithoutError[quotesWithoutError.length - 1]?.provider - ?.name - : undefined, - }); - } + }; - quotes - .filter((quote): quote is QuoteError => Boolean(quote.error)) - .forEach((quote) => - trackEvent('ONRAMP_QUOTE_ERROR', { - provider_onramp: quote.provider.name, + const averageOut = totals.amountOut / quotesWithoutError.length; + const providerList = quotesWithoutError.map( + ({ provider }) => provider.name, + ); + const providerFirst = quotesWithoutError[0]?.provider?.name; + const providerLast = + quotesWithoutError.length > 1 + ? quotesWithoutError[quotesWithoutError.length - 1]?.provider?.name + : undefined; + + if (isBuy) { + trackEvent('ONRAMP_QUOTES_RECEIVED', { + ...payload, currency_source: params.fiatCurrency?.symbol, currency_destination: params.asset?.symbol, - payment_method_id: selectedPaymentMethodId as string, + average_crypto_out: averageOut, chain_id_destination: selectedChainId, - error_message: quote.message, + provider_onramp_list: providerList, + provider_onramp_first: providerFirst, + provider_onramp_last: providerLast, + }); + } else { + trackEvent('OFFRAMP_QUOTES_RECEIVED', { + ...payload, + currency_destination: params.fiatCurrency?.symbol, + currency_source: params.asset?.symbol, + average_fiat_out: averageOut, + chain_id_source: selectedChainId, + provider_offramp_list: providerList, + provider_offramp_first: providerFirst, + provider_offramp_last: providerLast, + }); + } + } + + (quotes as (QuoteResponse | SellQuoteResponse | QuoteError)[]) + .filter((quote): quote is QuoteError => Boolean(quote.error)) + .forEach((quoteError) => { + const payload = { amount: params.amount, - }), - ); + payment_method_id: selectedPaymentMethodId as string, + error_message: quoteError.message, + }; + if (isBuy) { + trackEvent('ONRAMP_QUOTE_ERROR', { + ...payload, + currency_source: params.fiatCurrency?.symbol, + currency_destination: params.asset?.symbol, + provider_onramp: quoteError.provider.name, + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_QUOTE_ERROR', { + ...payload, + currency_destination: params.fiatCurrency?.symbol, + currency_source: params.asset?.symbol, + provider_offramp: quoteError.provider.name, + chain_id_source: selectedChainId, + }); + } + }); } }, [ appConfig.POLLING_CYCLES, filteredQuotes, + isBuy, isFetchingQuotes, params, pollingCyclesLeft, quotes, + rampType, selectedChainId, selectedPaymentMethodId, trackEvent, @@ -447,7 +550,9 @@ function Quotes() { navigation.goBack()} location={'Quotes Screen'} @@ -507,9 +612,10 @@ function Quotes() { isLoading={isQuoteLoading} quote={quote} onPress={() => handleOnQuotePress(quote)} - onPressBuy={() => handleOnPressBuy(quote, index)} + onPressCTA={() => handleOnPressCTA(quote, index)} highlighted={quote.provider.id === providerId} showInfo={() => handleInfoPress(quote)} + rampType={rampType} /> )) diff --git a/app/components/UI/Ramp/Views/Quotes/Quotes.types.ts b/app/components/UI/Ramp/buy/Views/Quotes/Quotes.types.ts similarity index 100% rename from app/components/UI/Ramp/Views/Quotes/Quotes.types.ts rename to app/components/UI/Ramp/buy/Views/Quotes/Quotes.types.ts diff --git a/app/components/UI/Ramp/Views/Quotes/Timer.tsx b/app/components/UI/Ramp/buy/Views/Quotes/Timer.tsx similarity index 82% rename from app/components/UI/Ramp/Views/Quotes/Timer.tsx rename to app/components/UI/Ramp/buy/Views/Quotes/Timer.tsx index 90ff196c423..972c843f919 100644 --- a/app/components/UI/Ramp/Views/Quotes/Timer.tsx +++ b/app/components/UI/Ramp/buy/Views/Quotes/Timer.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { ActivityIndicator, View } from 'react-native'; -import { useStyles } from '../../../../hooks/useStyles'; -import { useFiatOnRampSDK } from '../../sdk'; +import { useStyles } from '../../../../../hooks/useStyles'; +import { useRampSDK } from '../../../common/sdk'; -import Text from '../../../../Base/Text'; +import Text from '../../../../../Base/Text'; import styleSheet from './Quotes.styles'; -import { strings } from '../../../../../../locales/i18n'; +import { strings } from '../../../../../../../locales/i18n'; const Timer = ({ isFetchingQuotes, @@ -17,7 +17,7 @@ const Timer = ({ pollingCyclesLeft: number; remainingTime: number; }) => { - const { appConfig } = useFiatOnRampSDK(); + const { appConfig } = useRampSDK(); const { styles } = useStyles(styleSheet, {}); return ( diff --git a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/buy/Views/Quotes/__snapshots__/Quotes.test.tsx.snap similarity index 100% rename from app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap rename to app/components/UI/Ramp/buy/Views/Quotes/__snapshots__/Quotes.test.tsx.snap diff --git a/app/components/UI/Ramp/Views/Quotes/index.ts b/app/components/UI/Ramp/buy/Views/Quotes/index.ts similarity index 100% rename from app/components/UI/Ramp/Views/Quotes/index.ts rename to app/components/UI/Ramp/buy/Views/Quotes/index.ts diff --git a/app/components/UI/Ramp/Views/Regions/Regions.styles.ts b/app/components/UI/Ramp/buy/Views/Regions/Regions.styles.ts similarity index 100% rename from app/components/UI/Ramp/Views/Regions/Regions.styles.ts rename to app/components/UI/Ramp/buy/Views/Regions/Regions.styles.ts diff --git a/app/components/UI/Ramp/Views/Regions/Regions.test.tsx b/app/components/UI/Ramp/buy/Views/Regions/Regions.test.tsx similarity index 70% rename from app/components/UI/Ramp/Views/Regions/Regions.test.tsx rename to app/components/UI/Ramp/buy/Views/Regions/Regions.test.tsx index 2da747785fe..7652d507215 100644 --- a/app/components/UI/Ramp/Views/Regions/Regions.test.tsx +++ b/app/components/UI/Ramp/buy/Views/Regions/Regions.test.tsx @@ -1,21 +1,21 @@ import React from 'react'; import { Country } from '@consensys/on-ramp-sdk'; import { fireEvent, screen } from '@testing-library/react-native'; -import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { renderScreen } from '../../../../../../util/test/renderWithProvider'; import Regions from './Regions'; import useRegions from '../../hooks/useRegions'; -import { OnRampSDK } from '../../sdk'; -import { Region } from '../../types'; +import { RampSDK } from '../../../common/sdk'; +import { RampType, Region } from '../../../common/types'; import { createPaymentMethodsNavDetails } from '../PaymentMethods/PaymentMethods'; -import Routes from '../../../../../constants/navigation/Routes'; -import initialBackgroundState from '../../../../../util/test/initial-background-state.json'; +import Routes from '../../../../../../constants/navigation/Routes'; +import initialBackgroundState from '../../../../../../util/test/initial-background-state.json'; function render(Component: React.ComponentType) { return renderScreen( Component, { - name: Routes.FIAT_ON_RAMP_AGGREGATOR.REGION, + name: Routes.RAMP.REGION, }, { state: { @@ -30,20 +30,23 @@ function render(Component: React.ComponentType) { const mockSetSelectedRegion = jest.fn(); const mockSetSelectedCurrency = jest.fn(); -const mockuseFiatOnRampSDKInitialValues: Partial = { +const mockUseRampSDKInitialValues: Partial = { setSelectedRegion: mockSetSelectedRegion, setSelectedFiatCurrencyId: mockSetSelectedCurrency, sdkError: undefined, selectedChainId: '1', + rampType: RampType.BUY, + isBuy: true, + isSell: false, }; -let mockUseFiatOnRampSDKValues: Partial = { - ...mockuseFiatOnRampSDKInitialValues, +let mockUseRampSDKValues: Partial = { + ...mockUseRampSDKInitialValues, }; -jest.mock('../../sdk', () => ({ - ...jest.requireActual('../../sdk'), - useFiatOnRampSDK: () => mockUseFiatOnRampSDKValues, +jest.mock('../../../common/sdk', () => ({ + ...jest.requireActual('../../../common/sdk'), + useRampSDK: () => mockUseRampSDKValues, })); const mockQueryGetCountries = jest.fn(); @@ -56,6 +59,10 @@ const mockRegionsData = [ id: '/regions/cl', name: 'Chile', unsupported: false, + support: { + buy: true, + sell: true, + }, }, { currencies: ['/currencies/fiat/ars'], @@ -63,10 +70,14 @@ const mockRegionsData = [ id: '/regions/ar', name: 'Argentina', unsupported: false, + support: { + buy: true, + sell: true, + }, }, ] as Partial[]; -const mockuseRegionsInitialValues: Partial> = { +const mockUseRegionsInitialValues: Partial> = { data: mockRegionsData as Country[], isFetching: false, error: null, @@ -77,7 +88,7 @@ const mockuseRegionsInitialValues: Partial> = { }; let mockUseRegionsValues: Partial> = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, }; jest.mock('../../hooks/useRegions', () => jest.fn(() => mockUseRegionsValues)); @@ -103,7 +114,7 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); +jest.mock('../../../common/hooks/useAnalytics', () => () => mockTrackEvent); describe('Regions View', () => { afterEach(() => { @@ -111,20 +122,18 @@ describe('Regions View', () => { mockSetOptions.mockClear(); mockPop.mockClear(); mockTrackEvent.mockClear(); + (mockUseRampSDKInitialValues.setSelectedRegion as jest.Mock).mockClear(); ( - mockuseFiatOnRampSDKInitialValues.setSelectedRegion as jest.Mock - ).mockClear(); - ( - mockuseFiatOnRampSDKInitialValues.setSelectedFiatCurrencyId as jest.Mock + mockUseRampSDKInitialValues.setSelectedFiatCurrencyId as jest.Mock ).mockClear(); }); beforeEach(() => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, }; mockUseRegionsValues = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, }; }); @@ -140,7 +149,7 @@ describe('Regions View', () => { it('renders correctly while loading', async () => { mockUseRegionsValues = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, isFetching: true, }; render(Regions); @@ -149,7 +158,7 @@ describe('Regions View', () => { it('renders correctly with no data', async () => { mockUseRegionsValues = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, data: null, }; render(Regions); @@ -158,7 +167,7 @@ describe('Regions View', () => { it('renders correctly with selectedRegion', async () => { mockUseRegionsValues = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, selectedRegion: mockRegionsData[0] as Country, }; render(Regions); @@ -188,11 +197,18 @@ describe('Regions View', () => { }); fireEvent.press(regionButton); expect(mockSetSelectedRegion).toHaveBeenCalledWith(regionToPress); + expect(mockTrackEvent).toBeCalledWith('RAMP_REGION_SELECTED', { + country_id: '/regions/cl', + is_unsupported_onramp: false, + is_unsupported_offramp: false, + location: 'Region Screen', + state_id: '/regions/cl', + }); }); it('navigates on continue press', async () => { mockUseRegionsValues = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, selectedRegion: mockRegionsData[0] as Country, }; render(Regions); @@ -203,8 +219,8 @@ describe('Regions View', () => { }); it('navigates and tracks event on cancel button press', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, }; render(Regions); fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); @@ -213,6 +229,19 @@ describe('Regions View', () => { chain_id_destination: '1', location: 'Region Screen', }); + + mockTrackEvent.mockReset(); + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + isBuy: false, + isSell: true, + }; + render(Regions); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + chain_id_source: '1', + location: 'Region Screen', + }); }); it('has continue button disabled', async () => { @@ -223,16 +252,25 @@ describe('Regions View', () => { it('renders correctly with unsupportedRegion', async () => { mockUseRegionsValues = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, unsupportedRegion: mockRegionsData[1] as Region, }; render(Regions); expect(screen.toJSON()).toMatchSnapshot(); + + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + isBuy: false, + isSell: true, + rampType: RampType.SELL, + }; + render(Regions); + expect(screen.toJSON()).toMatchSnapshot(); }); it('renders correctly with sdkError', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, sdkError: new Error('sdkError'), }; render(Regions); @@ -240,8 +278,8 @@ describe('Regions View', () => { }); it('navigates to home when clicking sdKError button', async () => { - mockUseFiatOnRampSDKValues = { - ...mockuseFiatOnRampSDKInitialValues, + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, sdkError: new Error('sdkError'), }; render(Regions); @@ -253,7 +291,7 @@ describe('Regions View', () => { it('renders correctly with error', async () => { mockUseRegionsValues = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, error: 'Test error', }; render(Regions); @@ -262,7 +300,7 @@ describe('Regions View', () => { it('queries countries again with error', async () => { mockUseRegionsValues = { - ...mockuseRegionsInitialValues, + ...mockUseRegionsInitialValues, error: 'Test error', }; render(Regions); diff --git a/app/components/UI/Ramp/Views/Regions/Regions.tsx b/app/components/UI/Ramp/buy/Views/Regions/Regions.tsx similarity index 65% rename from app/components/UI/Ramp/Views/Regions/Regions.tsx rename to app/components/UI/Ramp/buy/Views/Regions/Regions.tsx index 65bedd65f1e..6dcf3ec7aed 100644 --- a/app/components/UI/Ramp/Views/Regions/Regions.tsx +++ b/app/components/UI/Ramp/buy/Views/Regions/Regions.tsx @@ -1,39 +1,39 @@ import React, { useCallback, useEffect } from 'react'; -import { View, TouchableOpacity } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import FontAwesome from 'react-native-vector-icons/FontAwesome'; import styles from './Regions.styles'; -import Text from '../../../../Base/Text'; -import BaseListItem from '../../../../Base/ListItem'; -import useModalHandler from '../../../../Base/hooks/useModalHandler'; - -import ScreenLayout from '../../components/ScreenLayout'; -import Box from '../../components/Box'; -import RegionModal from '../../components/RegionModal'; -import RegionAlert from '../../components/RegionAlert'; -import SkeletonText from '../../components/SkeletonText'; -import ErrorView from '../../components/ErrorView'; -import ErrorViewWithReporting from '../../components/ErrorViewWithReporting'; - -import StyledButton from '../../../StyledButton'; -import { getFiatOnRampAggNavbar } from '../../../Navbar'; -import { useTheme } from '../../../../../util/theme'; -import { strings } from '../../../../../../locales/i18n'; -import Routes from '../../../../../constants/navigation/Routes'; -import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; +import Text from '../../../../../Base/Text'; +import BaseListItem from '../../../../../Base/ListItem'; +import useModalHandler from '../../../../../Base/hooks/useModalHandler'; + +import ScreenLayout from '../../../common/components/ScreenLayout'; +import Box from '../../../common/components/Box'; +import RegionModal from '../../../common/components/RegionModal'; +import RegionAlert from '../../../common/components/RegionAlert'; +import SkeletonText from '../../../common/components/SkeletonText'; +import ErrorView from '../../../common/components/ErrorView'; +import ErrorViewWithReporting from '../../../common/components/ErrorViewWithReporting'; + +import StyledButton from '../../../../StyledButton'; +import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { useTheme } from '../../../../../../util/theme'; +import { strings } from '../../../../../../../locales/i18n'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { createNavigationDetails } from '../../../../../../util/navigation/navUtils'; import { createPaymentMethodsNavDetails } from '../PaymentMethods/PaymentMethods'; -import { useFiatOnRampSDK } from '../../sdk'; -import { Region } from '../../types'; -import useAnalytics from '../../hooks/useAnalytics'; +import { useRampSDK } from '../../../common/sdk'; +import { Region } from '../../../common/types'; +import useAnalytics from '../../../common/hooks/useAnalytics'; import useRegions from '../../hooks/useRegions'; // TODO: Convert into typescript and correctly type const ListItem = BaseListItem as any; export const createRegionsNavDetails = createNavigationDetails( - Routes.FIAT_ON_RAMP_AGGREGATOR.REGION, + Routes.RAMP.REGION, ); const RegionsView = () => { @@ -45,7 +45,10 @@ const RegionsView = () => { setSelectedFiatCurrencyId, sdkError, selectedChainId, - } = useFiatOnRampSDK(); + isBuy, + isSell, + rampType, + } = useRampSDK(); const [isRegionModalVisible, , showRegionModal, hideRegionModal] = useModalHandler(false); @@ -60,25 +63,36 @@ const RegionsView = () => { } = useRegions(); const handleCancelPress = useCallback(() => { - trackEvent('ONRAMP_CANCELED', { - location: 'Region Screen', - chain_id_destination: selectedChainId, - }); - }, [selectedChainId, trackEvent]); + if (isBuy) { + trackEvent('ONRAMP_CANCELED', { + location: 'Region Screen', + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_CANCELED', { + location: 'Region Screen', + chain_id_source: selectedChainId, + }); + } + }, [isBuy, selectedChainId, trackEvent]); useEffect(() => { navigation.setOptions( getFiatOnRampAggNavbar( navigation, { - title: strings('fiat_on_ramp_aggregator.region.buy_crypto_tokens'), + title: strings( + isBuy + ? 'fiat_on_ramp_aggregator.region.buy_crypto_tokens' + : 'fiat_on_ramp_aggregator.region.sell_crypto_tokens', + ), showBack: false, }, colors, handleCancelPress, ), ); - }, [navigation, colors, handleCancelPress]); + }, [isBuy, navigation, colors, handleCancelPress]); const handleOnPress = useCallback(() => { navigation.navigate(...createPaymentMethodsNavDetails()); @@ -138,7 +152,11 @@ const RegionsView = () => { @@ -179,7 +197,11 @@ const RegionsView = () => { {strings('fiat_on_ramp_aggregator.continue')} @@ -192,7 +214,16 @@ const RegionsView = () => { subtitle={`${unsupportedRegion?.emoji} ${unsupportedRegion?.name}`} dismiss={clearUnsupportedRegion} title={strings('fiat_on_ramp_aggregator.region.unsupported')} - body={strings('fiat_on_ramp_aggregator.region.unsupported_description')} + body={strings( + 'fiat_on_ramp_aggregator.region.unsupported_description', + { + rampType: strings( + isBuy + ? 'fiat_on_ramp_aggregator.buy' + : 'fiat_on_ramp_aggregator.sell', + ), + }, + )} link={strings('fiat_on_ramp_aggregator.region.unsupported_link')} /> @@ -206,6 +237,7 @@ const RegionsView = () => { dismiss={hideRegionModal as () => void} onRegionPress={handleRegionPress} location={'Region Screen'} + rampType={rampType} /> ); diff --git a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap b/app/components/UI/Ramp/buy/Views/Regions/__snapshots__/Regions.test.tsx.snap similarity index 84% rename from app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap rename to app/components/UI/Ramp/buy/Views/Regions/__snapshots__/Regions.test.tsx.snap index 4dfb7c51c9e..6763aa980f1 100644 --- a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap +++ b/app/components/UI/Ramp/buy/Views/Regions/__snapshots__/Regions.test.tsx.snap @@ -5430,6 +5430,1206 @@ exports[`Regions View renders correctly with unsupportedRegion 1`] = ` `; +exports[`Regions View renders correctly with unsupportedRegion 2`] = ` + + + + + + + + + + + + + + + + + Sell Crypto Tokens + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + Your Region + + + Cash destination options and tokens may vary depending on your region. + + + + + + + + + + + Select your region + + + + +  + + + + + + + + + + + + + + Continue + + + + + + + + + + + + + +  + + + + + Region Not Supported + + + 🇦🇷 Argentina + + + + We are working hard to expand coverage to your region as soon as we can. In the meantime, see our support article for other ways you may be able to sell crypto. + + + + + Visit Support Article + + + + + + + + + + + + + + + + + + + + + +`; + exports[`Regions View renders regions modal when pressing select button 1`] = ` { + if ( + !isFetchingCryptoCurrencies && + !errorCryptoCurrencies && + sdkCryptoCurrencies + ) { + const filteredTokens = sdkCryptoCurrencies.filter( + (token) => Number(token.network?.chainId) === Number(selectedChainId), + ); + return filteredTokens; + } + return null; + }, [ + errorCryptoCurrencies, + isFetchingCryptoCurrencies, + sdkCryptoCurrencies, + selectedChainId, + ]); + + /** + * Select the native crypto currency of first of the list + * if current selection is not available. + * This is using the already filtered list of tokens. + */ + useEffect(() => { + if (cryptoCurrencies) { + if ( + !selectedAsset || + !cryptoCurrencies.find( + (token) => token.address === selectedAsset.address, + ) + ) { + setSelectedAsset( + cryptoCurrencies.find((a) => a.address === NATIVE_ADDRESS) || + cryptoCurrencies?.[0], + ); + } + } + }, [cryptoCurrencies, selectedAsset, setSelectedAsset]); + + return { + cryptoCurrencies, + errorCryptoCurrencies, + isFetchingCryptoCurrencies, + queryGetCryptoCurrencies, + }; +} diff --git a/app/components/UI/Ramp/buy/hooks/useFiatCurrencies.ts b/app/components/UI/Ramp/buy/hooks/useFiatCurrencies.ts new file mode 100644 index 00000000000..bf8a5fba88c --- /dev/null +++ b/app/components/UI/Ramp/buy/hooks/useFiatCurrencies.ts @@ -0,0 +1,101 @@ +import { useEffect, useMemo } from 'react'; +import { useRampSDK } from '../../common/sdk'; +import useSDKMethod from '../../common/hooks/useSDKMethod'; + +export default function useFiatCurrencies() { + const { + selectedRegion, + selectedPaymentMethodId, + selectedFiatCurrencyId, + setSelectedFiatCurrencyId, + isBuy, + } = useRampSDK(); + + const [ + { + data: defaultFiatCurrency, + error: errorDefaultFiatCurrency, + isFetching: isFetchingDefaultFiatCurrency, + }, + queryDefaultFiatCurrency, + ] = useSDKMethod( + isBuy ? 'getDefaultFiatCurrency' : 'getDefaultSellFiatCurrency', + selectedRegion?.id, + selectedPaymentMethodId, + ); + + const [ + { + data: fiatCurrencies, + error: errorFiatCurrencies, + isFetching: isFetchingFiatCurrencies, + }, + queryGetFiatCurrencies, + ] = useSDKMethod( + isBuy ? 'getFiatCurrencies' : 'getSellFiatCurrencies', + selectedRegion?.id, + selectedPaymentMethodId, + ); + + /** + * Select the default fiat currency as selected if none is selected. + */ + useEffect(() => { + if ( + !isFetchingDefaultFiatCurrency && + defaultFiatCurrency && + !selectedFiatCurrencyId + ) { + setSelectedFiatCurrencyId(defaultFiatCurrency.id); + } + }, [ + defaultFiatCurrency, + isFetchingDefaultFiatCurrency, + selectedFiatCurrencyId, + setSelectedFiatCurrencyId, + ]); + + /** + * Select the default fiat currency if current selection is not available. + */ + useEffect(() => { + if ( + !isFetchingFiatCurrencies && + !isFetchingDefaultFiatCurrency && + selectedFiatCurrencyId && + fiatCurrencies && + defaultFiatCurrency && + !fiatCurrencies.some((currency) => currency.id === selectedFiatCurrencyId) + ) { + setSelectedFiatCurrencyId(defaultFiatCurrency.id); + } + }, [ + defaultFiatCurrency, + fiatCurrencies, + isFetchingDefaultFiatCurrency, + isFetchingFiatCurrencies, + selectedFiatCurrencyId, + setSelectedFiatCurrencyId, + ]); + + /** + * Get the fiat currency object by id + */ + const currentFiatCurrency = useMemo( + () => + fiatCurrencies?.find?.((curr) => curr.id === selectedFiatCurrencyId) || + defaultFiatCurrency, + [fiatCurrencies, defaultFiatCurrency, selectedFiatCurrencyId], + ); + + return { + defaultFiatCurrency, + queryDefaultFiatCurrency, + fiatCurrencies, + queryGetFiatCurrencies, + errorFiatCurrency: errorFiatCurrencies || errorDefaultFiatCurrency, + isFetchingFiatCurrency: + isFetchingFiatCurrencies || isFetchingDefaultFiatCurrency, + currentFiatCurrency, + }; +} diff --git a/app/components/UI/Ramp/buy/hooks/useHandleSuccessfulOrder.ts b/app/components/UI/Ramp/buy/hooks/useHandleSuccessfulOrder.ts new file mode 100644 index 00000000000..bc061efc0df --- /dev/null +++ b/app/components/UI/Ramp/buy/hooks/useHandleSuccessfulOrder.ts @@ -0,0 +1,143 @@ +import { CryptoCurrency, Order } from '@consensys/on-ramp-sdk'; +import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; +import { useNavigation } from '@react-navigation/native'; +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getNotificationDetails } from '../..'; +import { protectWalletModalVisible } from '../../../../../actions/user'; +import { NATIVE_ADDRESS } from '../../../../../constants/on-ramp'; +import Engine from '../../../../../core/Engine'; +import NotificationManager from '../../../../../core/NotificationManager'; +import { addFiatOrder, FiatOrder } from '../../../../../reducers/fiatOrders'; +import { toLowerCaseEquals } from '../../../../../util/general'; +import useThunkDispatch from '../../../../hooks/useThunkDispatch'; +import { useRampSDK } from '../../common/sdk'; +import { stateHasOrder } from '../../common/utils'; +import useAnalytics from '../../common/hooks/useAnalytics'; +import { hexToBN } from '../../../../../util/number'; +import { selectAccounts } from '../../../../../selectors/accountTrackerController'; +import Routes from '../../../../../constants/navigation/Routes'; + +function useHandleSuccessfulOrder() { + const { selectedChainId, selectedAddress } = useRampSDK(); + const navigation = useNavigation(); + const dispatch = useDispatch(); + const dispatchThunk = useThunkDispatch(); + const trackEvent = useAnalytics(); + const accounts = useSelector(selectAccounts); + + const addTokenToTokensController = useCallback( + async (token: CryptoCurrency) => { + if (!token) return; + + const { address, symbol, decimals, network, name } = token; + const chainId = network?.chainId; + + if ( + Number(chainId) !== Number(selectedChainId) || + address === NATIVE_ADDRESS + ) { + return; + } + + const { TokensController } = Engine.context; + + if ( + !TokensController.state.tokens.includes((t: any) => + toLowerCaseEquals(t.address, address), + ) + ) { + await TokensController.addToken(address, symbol, decimals, { name }); + } + }, + [selectedChainId], + ); + + const handleDispatchUserWalletProtection = useCallback(() => { + dispatch(protectWalletModalVisible()); + }, [dispatch]); + + const handleAddFiatOrder = useCallback( + (order) => { + dispatch(addFiatOrder(order)); + }, + [dispatch], + ); + + const handleSuccessfulOrder = useCallback( + async ( + order: FiatOrder, + params?: { + isApplePay?: boolean; + }, + ) => { + await addTokenToTokensController((order as any)?.data?.cryptoCurrency); + handleDispatchUserWalletProtection(); + // @ts-expect-error navigation prop mismatch + navigation.dangerouslyGetParent()?.pop(); + + dispatchThunk((_, getState) => { + const state = getState(); + if (stateHasOrder(state, order)) { + return; + } + handleAddFiatOrder(order); + const notificationDetails = getNotificationDetails(order as any); + if (notificationDetails) { + NotificationManager.showSimpleNotification(notificationDetails); + } + + const payload = { + payment_method_id: (order?.data as Order)?.paymentMethod?.id, + order_type: order?.orderType, + is_apple_pay: Boolean(params?.isApplePay), + }; + + if (order.orderType === OrderOrderTypeEnum.Sell) { + trackEvent('OFFRAMP_PURCHASE_SUBMITTED', { + ...payload, + provider_offramp: (order?.data as Order)?.provider?.name, + chain_id_source: selectedChainId, + currency_source: (order?.data as Order)?.cryptoCurrency.symbol, + currency_destination: (order?.data as Order)?.fiatCurrency.symbol, + }); + navigation.navigate(Routes.TRANSACTIONS_VIEW, { + screen: Routes.RAMP.ORDER_DETAILS, + initial: false, + params: { + orderId: order.id, + redirectToSendTransaction: true, + }, + }); + } else { + trackEvent('ONRAMP_PURCHASE_SUBMITTED', { + ...payload, + provider_onramp: (order?.data as Order)?.provider?.name, + chain_id_destination: selectedChainId, + has_zero_currency_destination_balance: false, + has_zero_native_balance: accounts[selectedAddress]?.balance + ? (hexToBN(accounts[selectedAddress].balance) as any)?.isZero?.() + : undefined, + currency_source: (order?.data as Order)?.fiatCurrency.symbol, + currency_destination: (order?.data as Order)?.cryptoCurrency.symbol, + }); + } + }); + }, + [ + accounts, + addTokenToTokensController, + dispatchThunk, + handleAddFiatOrder, + handleDispatchUserWalletProtection, + navigation, + selectedAddress, + selectedChainId, + trackEvent, + ], + ); + + return handleSuccessfulOrder; +} + +export default useHandleSuccessfulOrder; diff --git a/app/components/UI/Ramp/hooks/useInAppBrowser.ts b/app/components/UI/Ramp/buy/hooks/useInAppBrowser.ts similarity index 74% rename from app/components/UI/Ramp/hooks/useInAppBrowser.ts rename to app/components/UI/Ramp/buy/hooks/useInAppBrowser.ts index 2ccce5a8eae..12387365c97 100644 --- a/app/components/UI/Ramp/hooks/useInAppBrowser.ts +++ b/app/components/UI/Ramp/buy/hooks/useInAppBrowser.ts @@ -3,20 +3,21 @@ import { Linking } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; import InAppBrowser from 'react-native-inappbrowser-reborn'; import { OrderStatusEnum, Provider } from '@consensys/on-ramp-sdk'; +import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; import BuyAction from '@consensys/on-ramp-sdk/dist/regions/BuyAction'; -import useAnalytics from './useAnalytics'; -import useHandleSuccessfulOrder from './useHandleSuccessfulOrder'; -import { callbackBaseDeeplink, SDK, useFiatOnRampSDK } from '../sdk'; -import { createCustomOrderIdData } from '../orderProcessor/customOrderId'; -import { aggregatorOrderToFiatOrder } from '../orderProcessor/aggregator'; +import useAnalytics from '../../common/hooks/useAnalytics'; +import { callbackBaseDeeplink, SDK, useRampSDK } from '../../common/sdk'; +import { createCustomOrderIdData } from '../../common/orderProcessor/customOrderId'; +import { aggregatorOrderToFiatOrder } from '../../common/orderProcessor/aggregator'; import { addFiatCustomIdData, FiatOrder, removeFiatCustomIdData, -} from '../../../../reducers/fiatOrders'; -import { setLockTime } from '../../../../actions/settings'; -import Logger from '../../../../util/Logger'; -import Device from '../../../../util/device'; +} from '../../../../../reducers/fiatOrders'; +import { setLockTime } from '../../../../../actions/settings'; +import Logger from '../../../../../util/Logger'; +import useHandleSuccessfulOrder from './useHandleSuccessfulOrder'; +import Device from '../../../../../util/device'; export default function useInAppBrowser() { const { @@ -24,7 +25,8 @@ export default function useInAppBrowser() { selectedPaymentMethodId, selectedAsset, selectedChainId, - } = useFiatOnRampSDK(); + isBuy, + } = useRampSDK(); const dispatch = useDispatch(); const trackEvent = useAnalytics(); @@ -50,6 +52,7 @@ export default function useInAppBrowser() { customOrderId, selectedChainId, selectedAddress, + isBuy ? OrderOrderTypeEnum.Buy : OrderOrderTypeEnum.Sell, ); dispatch(addFiatCustomIdData(customIdData)); } @@ -66,17 +69,27 @@ export default function useInAppBrowser() { trackEvent('ONRAMP_PURCHASE_CANCELLED', { amount: amount as number, chain_id_destination: selectedChainId, - currency_destination: selectedAsset?.symbol as string, - currency_source: fiatSymbol as string, + currency_destination: isBuy + ? (selectedAsset?.symbol as string) + : (fiatSymbol as string), + currency_source: isBuy + ? (fiatSymbol as string) + : (selectedAsset?.symbol as string), payment_method_id: selectedPaymentMethodId as string, provider_onramp: provider.name, + order_type: isBuy + ? OrderOrderTypeEnum.Buy + : OrderOrderTypeEnum.Sell, }); return; } const orders = await SDK.orders(); - const order = await orders.getOrderFromCallback( + const getOrderFromCallbackMethod = isBuy + ? 'getOrderFromCallback' + : 'getSellOrderFromCallback'; + const order = await orders[getOrderFromCallbackMethod]( provider.id, result.url, selectedAddress, @@ -122,6 +135,7 @@ export default function useInAppBrowser() { [ dispatch, handleSuccessfulOrder, + isBuy, lockTime, selectedAddress, selectedAsset?.symbol, diff --git a/app/components/UI/Ramp/buy/hooks/useLimits.ts b/app/components/UI/Ramp/buy/hooks/useLimits.ts new file mode 100644 index 00000000000..d0ba508c077 --- /dev/null +++ b/app/components/UI/Ramp/buy/hooks/useLimits.ts @@ -0,0 +1,38 @@ +import useSDKMethod from '../../common/hooks/useSDKMethod'; +import { useRampSDK } from '../../common/sdk'; + +const useLimits = () => { + const { + selectedRegion, + selectedPaymentMethodId, + selectedAsset, + selectedFiatCurrencyId, + isBuy, + } = useRampSDK(); + + const [{ data: limits }] = useSDKMethod( + isBuy ? 'getLimits' : 'getSellLimits', + selectedRegion?.id, + selectedPaymentMethodId, + selectedAsset?.id, + selectedFiatCurrencyId, + ); + + const isAmountBelowMinimum = (amount: number) => + amount !== 0 && limits && amount < limits.minAmount; + + const isAmountAboveMaximum = (amount: number) => + amount !== 0 && limits && amount > limits.maxAmount; + + const isAmountValid = (amount: number) => + !isAmountBelowMinimum(amount) && !isAmountAboveMaximum(amount); + + return { + limits, + isAmountBelowMinimum, + isAmountAboveMaximum, + isAmountValid, + }; +}; + +export default useLimits; diff --git a/app/components/UI/Ramp/hooks/usePaymentMethods.ts b/app/components/UI/Ramp/buy/hooks/usePaymentMethods.ts similarity index 85% rename from app/components/UI/Ramp/hooks/usePaymentMethods.ts rename to app/components/UI/Ramp/buy/hooks/usePaymentMethods.ts index ac2d8570fef..9ddc01579ea 100644 --- a/app/components/UI/Ramp/hooks/usePaymentMethods.ts +++ b/app/components/UI/Ramp/buy/hooks/usePaymentMethods.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { useFiatOnRampSDK } from '../sdk'; -import useSDKMethod from './useSDKMethod'; +import { useRampSDK } from '../../common/sdk'; +import useSDKMethod from '../../common/hooks/useSDKMethod'; function usePaymentMethods() { const { @@ -9,13 +9,17 @@ function usePaymentMethods() { setSelectedPaymentMethodId, selectedChainId, sdk, - } = useFiatOnRampSDK(); + isBuy, + } = useRampSDK(); const [isFilterLoading, setIsFilterLoading] = useState(true); const [allowedMethodIds, setAllowedMethodIds] = useState(); - const [{ data: paymentMethods, isFetching, error }, queryGetPaymentMethods] = - useSDKMethod('getPaymentMethods', selectedRegion?.id); + const paymentMethodsMethod = isBuy + ? 'getPaymentMethods' + : 'getSellPaymentMethods'; + const [{ data: paymentMethods, isFetching, error }, queryGetPaymentMethods] = + useSDKMethod(paymentMethodsMethod, selectedRegion?.id); useEffect(() => setAllowedMethodIds(undefined), [selectedRegion]); useEffect(() => { @@ -27,7 +31,11 @@ function usePaymentMethods() { if (!method.customAction) { allowed.push(method.id); } else { - const cryptoCurrencies = await sdk?.getCryptoCurrencies( + const cryptoCurrenciesMethod = isBuy + ? 'getCryptoCurrencies' + : 'getSellCryptoCurrencies'; + + const cryptoCurrencies = await sdk?.[cryptoCurrenciesMethod]( selectedRegion.id, method.id, ); diff --git a/app/components/UI/Ramp/hooks/useQuotes.ts b/app/components/UI/Ramp/buy/hooks/useQuotes.ts similarity index 71% rename from app/components/UI/Ramp/hooks/useQuotes.ts rename to app/components/UI/Ramp/buy/hooks/useQuotes.ts index 1b30d97bcb4..ed14b0646be 100644 --- a/app/components/UI/Ramp/hooks/useQuotes.ts +++ b/app/components/UI/Ramp/buy/hooks/useQuotes.ts @@ -1,5 +1,5 @@ -import { useFiatOnRampSDK } from '../sdk'; -import useSDKMethod from './useSDKMethod'; +import { useRampSDK } from '../../common/sdk'; +import useSDKMethod from '../../common/hooks/useSDKMethod'; function useQuotes(amount: number) { const { @@ -8,9 +8,10 @@ function useQuotes(amount: number) { selectedAsset, selectedAddress, selectedFiatCurrencyId, - } = useFiatOnRampSDK(); + isBuy, + } = useRampSDK(); const [{ data, isFetching, error }, query] = useSDKMethod( - 'getQuotes', + isBuy ? 'getQuotes' : 'getSellQuotes', selectedRegion?.id, selectedPaymentMethodId, selectedAsset?.id, diff --git a/app/components/UI/Ramp/hooks/useRegions.ts b/app/components/UI/Ramp/buy/hooks/useRegions.ts similarity index 60% rename from app/components/UI/Ramp/hooks/useRegions.ts rename to app/components/UI/Ramp/buy/hooks/useRegions.ts index 74111e21305..784abd98cad 100644 --- a/app/components/UI/Ramp/hooks/useRegions.ts +++ b/app/components/UI/Ramp/buy/hooks/useRegions.ts @@ -1,9 +1,9 @@ import { useNavigation, useRoute } from '@react-navigation/native'; import { useCallback, useEffect, useMemo } from 'react'; -import Routes from '../../../../constants/navigation/Routes'; -import { useFiatOnRampSDK } from '../sdk'; -import { Region } from '../types'; -import useSDKMethod from './useSDKMethod'; +import Routes from '../../../../../constants/navigation/Routes'; +import { useRampSDK } from '../../common/sdk'; +import { Region } from '../../common/types'; +import useSDKMethod from '../../common/hooks/useSDKMethod'; export default function useRegions() { const navigation = useNavigation(); @@ -13,7 +13,9 @@ export default function useRegions() { setSelectedRegion, unsupportedRegion, setUnsupportedRegion, - } = useFiatOnRampSDK(); + isBuy, + isSell, + } = useRampSDK(); const [{ data, isFetching, error }, queryGetCountries] = useSDKMethod('getCountries'); @@ -31,24 +33,34 @@ export default function useRegions() { return allRegions.find((region) => region.id === selectedRegion.id) ?? null; }, [data, selectedRegion]); + const redirectToRegion = useCallback(() => { + if ( + route.name !== Routes.RAMP.REGION && + route.name !== Routes.RAMP.REGION_HAS_STARTED + ) { + navigation.reset({ + index: 0, + routes: [ + { + name: Routes.RAMP.REGION_HAS_STARTED, + }, + ], + }); + } + }, [navigation, route.name]); + useEffect(() => { if (updatedRegion?.unsupported) { setSelectedRegion(null); setUnsupportedRegion(updatedRegion); - - if ( - route.name !== Routes.FIAT_ON_RAMP_AGGREGATOR.REGION && - route.name !== Routes.FIAT_ON_RAMP_AGGREGATOR.REGION_HAS_STARTED - ) { - navigation.reset({ - index: 0, - routes: [ - { - name: Routes.FIAT_ON_RAMP_AGGREGATOR.REGION, - }, - ], - }); - } + redirectToRegion(); + } else if ( + updatedRegion && + ((isBuy && !updatedRegion.support.buy) || + (isSell && !updatedRegion.support.sell)) + ) { + setUnsupportedRegion(updatedRegion); + redirectToRegion(); } }, [ updatedRegion, @@ -56,6 +68,9 @@ export default function useRegions() { navigation, route.name, setUnsupportedRegion, + redirectToRegion, + isBuy, + isSell, ]); const clearUnsupportedRegion = useCallback( diff --git a/app/components/UI/Ramp/common/Views/NetworkSwitcher/LoadingNetworksSkeleton.tsx b/app/components/UI/Ramp/common/Views/NetworkSwitcher/LoadingNetworksSkeleton.tsx new file mode 100644 index 00000000000..ee5e01696a7 --- /dev/null +++ b/app/components/UI/Ramp/common/Views/NetworkSwitcher/LoadingNetworksSkeleton.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import BaseListItem from '../../../../../Base/ListItem'; +import Row from '../../components/Row'; +import SkeletonText from '../../components/SkeletonText'; + +const ListItem = BaseListItem as any; + +function LoadingNetworkSkeleton() { + return ( + + + + + + + + + ); +} + +function LoadingNetworksSkeleton() { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default LoadingNetworksSkeleton; diff --git a/app/components/UI/Ramp/common/Views/NetworkSwitcher/NetworkSwitcher.test.tsx b/app/components/UI/Ramp/common/Views/NetworkSwitcher/NetworkSwitcher.test.tsx new file mode 100644 index 00000000000..7d91b8aea96 --- /dev/null +++ b/app/components/UI/Ramp/common/Views/NetworkSwitcher/NetworkSwitcher.test.tsx @@ -0,0 +1,376 @@ +import React from 'react'; +import { AggregatorNetwork } from '@consensys/on-ramp-sdk/dist/API'; +import { fireEvent, screen } from '@testing-library/react-native'; +import { renderScreen } from '../../../../../../util/test/renderWithProvider'; + +import NetworkSwitcher from './NetworkSwitcher'; +import useFetchRampNetworks from '../../hooks/useFetchRampNetworks'; +import useRampNetworksDetail from '../../hooks/useRampNetworksDetail'; +import { RampSDK } from '../../sdk'; +import Routes from '../../../../../../constants/navigation/Routes'; +import initialBackgroundState from '../../../../../../util/test/initial-background-state.json'; +import Engine from '../../../../../../core/Engine'; +import { RampType } from '../../../../../../reducers/fiatOrders/types'; + +const mockedRampNetworksValues: AggregatorNetwork[] = [ + { + active: true, + chainId: 1, + chainName: 'Ethereum Mainnet', + nativeTokenSupported: true, + shortName: 'Ethereum', + }, + { + active: true, + chainId: 59144, + chainName: 'Linea Mainnet', + nativeTokenSupported: true, + shortName: 'Linea', + }, + { + active: true, + chainId: 25, + chainName: 'Cronos Mainnet', + nativeTokenSupported: true, + shortName: 'Cronos', + }, + { + active: true, + chainId: 137, + chainName: 'Polygon Mainnet', + nativeTokenSupported: true, + shortName: 'Polygon', + }, + { + active: false, + chainId: 56, + chainName: 'BNB Smart Chain', + nativeTokenSupported: false, + shortName: 'BNB Smart Chain', + }, +]; + +let mockedRampNetworks = [...mockedRampNetworksValues]; + +const mockedNetworksDetails = [ + { + chainId: '25', + nickname: 'Cronos Mainnet', + rpcUrl: 'https://evm.cronos.org', + ticker: 'CRO', + rpcPrefs: { + blockExplorerUrl: 'https://cronoscan.com', + imageUrl: + 'https://static.metafi.codefi.network/api/v1/tokenIcons/42220/0x471ece3750da237f93b8e339c536989b8978a438.png', + }, + }, +]; + +function render(Component: React.ComponentType, chainId?: string) { + return renderScreen( + Component, + { + name: Routes.RAMP.NETWORK_SWITCHER, + }, + { + state: { + engine: { + backgroundState: { + ...initialBackgroundState, + NetworkController: { + ...initialBackgroundState.NetworkController, + providerConfig: { + chainId: chainId ?? '56', + ticker: 'BNB', + nickname: 'BNB Smart Chain', + }, + networkConfigurations: { + networkId1: { + chainId: '137', + nickname: 'Polygon Mainnet', + rpcPrefs: { blockExplorerUrl: 'https://polygonscan.com' }, + rpcUrl: + 'https://polygon-mainnet.infura.io/v3/cda392a134014865ad3c273dc7ddfff3', + ticker: 'MATIC', + }, + }, + }, + }, + }, + fiatOrders: { + networks: mockedRampNetworks, + }, + }, + }, + ); +} + +jest.mock('../../../../../../core/Engine', () => ({ + context: { + NetworkController: { + setProviderType: jest.fn(), + setActiveNetwork: jest.fn(), + }, + CurrencyRateController: { + setNativeCurrency: jest.fn(), + }, + }, +})); + +const mockSetOptions = jest.fn(); +const mockNavigate = jest.fn(); +const mockPop = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: mockSetOptions.mockImplementation( + actualReactNavigation.useNavigation().setOptions, + ), + dangerouslyGetParent: () => ({ + pop: mockPop, + }), + }), + }; +}); + +const mockTrackEvent = jest.fn(); +jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); + +const mockFetchNetworks = jest.fn(); +const mockUseFetchRampNetworksInitialValues: Partial< + ReturnType +> = [false, undefined, mockFetchNetworks]; + +let mockUseFetchRampNetworksValues = [...mockUseFetchRampNetworksInitialValues]; + +jest.mock('../../hooks/useFetchRampNetworks', () => + jest.fn(() => mockUseFetchRampNetworksValues), +); + +const mockGetNetworksDetail = jest.fn(); +const mockUseRampNetworksDetailInitialValues: Partial< + ReturnType +> = { + error: undefined, + isLoading: false, + getNetworksDetail: mockGetNetworksDetail, + networksDetails: mockedNetworksDetails, +}; + +let mockUseRampNetworksDetailValues = { + ...mockUseRampNetworksDetailInitialValues, +}; + +jest.mock('../../hooks/useRampNetworksDetail', () => + jest.fn(() => mockUseRampNetworksDetailValues), +); + +const mockuseRampSDKInitialValues: Partial = { + selectedChainId: '56', + isBuy: true, + isSell: false, + rampType: RampType.BUY, +}; + +let mockUseRampSDKValues: Partial = { + ...mockuseRampSDKInitialValues, +}; + +jest.mock('../../sdk', () => ({ + ...jest.requireActual('../../sdk'), + useRampSDK: () => mockUseRampSDKValues, +})); + +describe('NetworkSwitcher View', () => { + afterEach(() => { + mockNavigate.mockClear(); + mockSetOptions.mockClear(); + mockPop.mockClear(); + mockTrackEvent.mockClear(); + ( + mockUseRampNetworksDetailInitialValues.getNetworksDetail as jest.Mock + ).mockClear(); + mockFetchNetworks.mockClear(); + }); + + beforeEach(() => { + mockedRampNetworks = [...mockedRampNetworksValues]; + mockUseFetchRampNetworksValues = [...mockUseFetchRampNetworksInitialValues]; + mockUseRampNetworksDetailValues = { + ...mockUseRampNetworksDetailInitialValues, + }; + mockUseRampSDKValues = { + ...mockuseRampSDKInitialValues, + }; + }); + + it('calls setOptions when rendering', async () => { + render(NetworkSwitcher); + expect(mockSetOptions).toBeCalledTimes(1); + }); + + it('renders correctly', async () => { + render(NetworkSwitcher); + expect(screen.toJSON()).toMatchSnapshot(); + + // check for sell title + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; + render(NetworkSwitcher); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders correctly while loading', async () => { + mockUseFetchRampNetworksValues = [ + true, + ...mockUseFetchRampNetworksInitialValues.slice(1), + ]; + render(NetworkSwitcher); + + expect(screen.toJSON()).toMatchSnapshot(); + + mockUseFetchRampNetworksValues = [...mockUseFetchRampNetworksInitialValues]; + mockUseRampNetworksDetailValues = { + ...mockUseRampNetworksDetailInitialValues, + isLoading: true, + }; + render(NetworkSwitcher); + + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders correctly with no data', async () => { + mockedRampNetworks = []; + render(NetworkSwitcher); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders and dismisses network modal when pressing add button', async () => { + render(NetworkSwitcher); + const cancelButtons = screen.getAllByText('Cancel'); + + expect(cancelButtons.length).toBe(1); + + const selectRegionButton = screen.getByText('Add'); + fireEvent.press(selectRegionButton); + expect(screen.toJSON()).toMatchSnapshot(); + + const cancelButtons2 = screen.getAllByText('Cancel'); + expect(cancelButtons2.length).toBe(2); + fireEvent.press(cancelButtons2[1]); + expect(screen.toJSON()).toMatchSnapshot(); + const cancelButtons3 = screen.getAllByText('Cancel'); + expect(cancelButtons3.length).toBe(1); + }); + + it('switches network by calling setProviderType', async () => { + render(NetworkSwitcher); + const lineaNetworkText = screen.getByText('Linea Main Network'); + fireEvent.press(lineaNetworkText); + expect(Engine.context.NetworkController.setProviderType.mock.calls) + .toMatchInlineSnapshot(` + Array [ + Array [ + "linea-mainnet", + ], + ] + `); + + render(NetworkSwitcher); + const polygonNetworkTest = screen.getByText('Polygon Mainnet'); + fireEvent.press(polygonNetworkTest); + expect(Engine.context.NetworkController.setActiveNetwork.mock.calls) + .toMatchInlineSnapshot(` + Array [ + Array [ + "networkId1", + ], + ] + `); + expect(Engine.context.CurrencyRateController.setNativeCurrency.mock.calls) + .toMatchInlineSnapshot(` + Array [ + Array [ + "MATIC", + ], + ] + `); + }); + + it('renders correctly with errors', async () => { + mockUseFetchRampNetworksValues = [ + mockUseFetchRampNetworksInitialValues[0], + new Error('Test Error for fetching networks'), + mockUseFetchRampNetworksInitialValues[2], + ]; + + render(NetworkSwitcher); + expect(screen.toJSON()).toMatchSnapshot(); + expect(screen.getByText('Test Error for fetching networks')).toBeTruthy(); + + mockUseFetchRampNetworksValues = [...mockUseFetchRampNetworksInitialValues]; + mockUseRampNetworksDetailValues = { + ...mockUseRampNetworksDetailInitialValues, + error: new Error('Test Error for fetching networks details'), + }; + + render(NetworkSwitcher); + expect(screen.toJSON()).toMatchSnapshot(); + expect( + screen.getByText('Test Error for fetching networks details'), + ).toBeTruthy(); + }); + + it('retries fetching networks and details when pressing the error view button', async () => { + mockUseFetchRampNetworksValues = [ + mockUseFetchRampNetworksInitialValues[0], + new Error('Test Error for fetching networks'), + mockUseFetchRampNetworksInitialValues[2], + ]; + + render(NetworkSwitcher); + const tryAgainButton = screen.getByRole('button', { name: 'Try again' }); + fireEvent.press(tryAgainButton); + expect(mockFetchNetworks).toBeCalledTimes(1); + expect(mockGetNetworksDetail).toBeCalledTimes(1); + }); + + it('navigates and tracks event on cancel button press', async () => { + render(NetworkSwitcher); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockPop).toHaveBeenCalled(); + expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', { + chain_id_destination: '56', + location: 'Network Switcher Screen', + }); + + // test for sell copy + mockPop.mockReset(); + mockTrackEvent.mockReset(); + mockUseRampSDKValues.rampType = RampType.SELL; + mockUseRampSDKValues.isSell = true; + mockUseRampSDKValues.isBuy = false; + render(NetworkSwitcher); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + chain_id_source: '56', + location: 'Network Switcher Screen', + }); + }); + + it('navigates on supported network', async () => { + render(NetworkSwitcher, '1'); + expect(mockNavigate.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "GetStarted", + ], + ] + `); + }); +}); diff --git a/app/components/UI/Ramp/common/Views/NetworkSwitcher/NetworkSwitcher.tsx b/app/components/UI/Ramp/common/Views/NetworkSwitcher/NetworkSwitcher.tsx new file mode 100644 index 00000000000..ab8c7742500 --- /dev/null +++ b/app/components/UI/Ramp/common/Views/NetworkSwitcher/NetworkSwitcher.tsx @@ -0,0 +1,299 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { RefreshControl, TouchableOpacity, View } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { NetworksChainId, NetworkType } from '@metamask/controller-utils'; +import { useSelector } from 'react-redux'; + +import LoadingNetworksSkeleton from './LoadingNetworksSkeleton'; +import ScreenLayout from '../../components/ScreenLayout'; +import ErrorView from '../../components/ErrorView'; +import Row from '../../components/Row'; + +import Avatar, { + AvatarSize, + AvatarVariant, +} from '../../../../../../component-library/components/Avatars/Avatar'; +import imageIcons from '../../../../../../images/image-icons'; +import Text from '../../../../../Base/Text'; +import CustomNetwork from '../../../../../Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork'; +import customNetworkStyles from '../../../../../Views/Settings/NetworksSettings/NetworkSettings/styles'; +import { Network } from '../../../../../Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.types'; + +import useFetchRampNetworks from '../../hooks/useFetchRampNetworks'; +import useRampNetwork from '../../hooks/useRampNetwork'; +import useRampNetworksDetail from '../../hooks/useRampNetworksDetail'; +import useAnalytics from '../../hooks/useAnalytics'; +import { getRampNetworks } from '../../../../../../reducers/fiatOrders'; +import { useRampSDK } from '../../sdk'; +import { isNetworkRampSupported } from '../../utils'; + +import Engine from '../../../../../../core/Engine'; +import { useTheme } from '../../../../../../util/theme'; +import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { selectNetworkConfigurations } from '../../../../../../selectors/networkController'; +import { strings } from '../../../../../../../locales/i18n'; +import Routes from '../../../../../../constants/navigation/Routes'; + +import PopularList from '../../../../../../util/networks/customNetworks'; + +function NetworkSwitcher() { + const navigation = useNavigation(); + const { colors } = useTheme(); + const customNetworkStyle = customNetworkStyles(); + const trackEvent = useAnalytics(); + + const [isLoadingNetworks, errorFetchingNetworks, fetchNetworks] = + useFetchRampNetworks(); + const { + networksDetails, + isLoading: isLoadingNetworksDetail, + error: errorFetchingNetworksDetail, + getNetworksDetail, + } = useRampNetworksDetail(); + const supportedNetworks = useSelector(getRampNetworks); + const [isCurrentNetworkRampSupported] = useRampNetwork(); + const { selectedChainId, isBuy } = useRampSDK(); + + const networkConfigurations = useSelector(selectNetworkConfigurations); + const [networkToBeAdded, setNetworkToBeAdded] = useState(); + + const isLoading = isLoadingNetworks || isLoadingNetworksDetail; + const error = errorFetchingNetworks || errorFetchingNetworksDetail; + const rampNetworks = useMemo(() => { + const activeNetworkDetails: Network[] = []; + supportedNetworks.forEach(({ chainId: supportedChainId, active }) => { + const currentChainId = `${supportedChainId}`; + if ( + currentChainId === NetworksChainId['linea-mainnet'] || + currentChainId === NetworksChainId.mainnet || + !active + ) { + return; + } + + const popularNetwork = PopularList.find( + ({ chainId }) => chainId === currentChainId, + ); + + if (popularNetwork) { + activeNetworkDetails.push(popularNetwork); + return; + } + + const networkDetail = networksDetails.find( + ({ chainId }) => chainId === currentChainId, + ); + if (networkDetail) { + activeNetworkDetails.push(networkDetail); + } + }); + + return activeNetworkDetails; + }, [networksDetails, supportedNetworks]); + + const handleCancelPress = useCallback(() => { + if (isBuy) { + trackEvent('ONRAMP_CANCELED', { + location: 'Network Switcher Screen', + chain_id_destination: selectedChainId, + }); + } else { + trackEvent('OFFRAMP_CANCELED', { + location: 'Network Switcher Screen', + chain_id_source: selectedChainId, + }); + } + }, [isBuy, selectedChainId, trackEvent]); + + useEffect(() => { + navigation.setOptions( + getFiatOnRampAggNavbar( + navigation, + { + title: strings('fiat_on_ramp_aggregator.network_switcher.title', { + rampType: strings( + isBuy + ? 'fiat_on_ramp_aggregator.buy' + : 'fiat_on_ramp_aggregator.sell', + ), + }), + showBack: false, + }, + colors, + handleCancelPress, + ), + ); + }, [isBuy, navigation, colors, handleCancelPress]); + + useEffect(() => { + if (isCurrentNetworkRampSupported) { + navigation.navigate(Routes.RAMP.GET_STARTED); + } + }, [isCurrentNetworkRampSupported, navigation]); + + const switchToMainnet = useCallback((type: 'mainnet' | 'linea-mainnet') => { + const { NetworkController } = Engine.context; + NetworkController.setProviderType(type as NetworkType); + }, []); + + const switchNetwork = useCallback( + (networkConfiguration) => { + const { CurrencyRateController, NetworkController } = Engine.context; + const entry = Object.entries(networkConfigurations).find( + ([_a, { chainId }]) => chainId === networkConfiguration.chainId, + ); + + if (entry) { + const [networkConfigurationId] = entry; + const { ticker } = networkConfiguration; + + CurrencyRateController.setNativeCurrency(ticker); + NetworkController.setActiveNetwork(networkConfigurationId); + } + }, + [networkConfigurations], + ); + + const handleNetworkPress = useCallback( + (networkConfiguration) => { + if (networkConfiguration.isAdded) { + switchNetwork(networkConfiguration); + } else { + setNetworkToBeAdded(networkConfiguration); + } + }, + [switchNetwork], + ); + + const handleNetworkModalClose = useCallback(() => { + if (networkToBeAdded) { + setNetworkToBeAdded(undefined); + } + }, [networkToBeAdded]); + + if (!isLoading && (error || rampNetworks.length === 0)) { + return ( + + + { + getNetworksDetail(); + fetchNetworks(); + }} + location={'Network Switcher Screen'} + /> + + + ); + } + + return ( + + { + getNetworksDetail(); + fetchNetworks(); + }} + /> + } + > + + + + {strings('fiat_on_ramp_aggregator.network_switcher.description', { + rampType: strings( + isBuy + ? 'fiat_on_ramp_aggregator.buy' + : 'fiat_on_ramp_aggregator.sell', + ), + })} + + + + + {isLoading ? ( + + ) : ( + <> + {isNetworkRampSupported( + NetworksChainId.mainnet, + supportedNetworks, + ) ? ( + switchToMainnet('mainnet')} + > + + + + + + Ethereum Main Network + + + {strings('networks.switch')} + + + ) : null} + {isNetworkRampSupported( + NetworksChainId['linea-mainnet'], + supportedNetworks, + ) ? ( + switchToMainnet('linea-mainnet')} + > + + + + + Linea Main Network + + + {strings('networks.switch')} + + + ) : null} + + undefined} + shouldNetworkSwitchPopToWallet={false} + customNetworksList={rampNetworks} + /> + + )} + + + + + ); +} + +export default NetworkSwitcher; diff --git a/app/components/UI/Ramp/common/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap b/app/components/UI/Ramp/common/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap new file mode 100644 index 00000000000..d02a86df45f --- /dev/null +++ b/app/components/UI/Ramp/common/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap @@ -0,0 +1,10155 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NetworkSwitcher View renders and dismisses network modal when pressing add button 1`] = ` + + + + + + + + + + + + + + + + + Unsupported buy Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + To buy crypto, you'll need to switch to a supported network + + + + + + + + + + + Ethereum Main Network + + + + + Switch + + + + + + + + + + + + Linea Main Network + + + + + Switch + + + + + + + + + + + Cronos Mainnet + + + + Want to add this network? + + + This allows this network to be used within MetaMask + + + MetaMask does not endorse custom networks or their security. + + +  + + + + + + Learn about + + + + scams and network security risks + + + + + + Display name + + + Cronos Mainnet + + + Chain ID + + + 25 + + + Network URL + + + https://evm.cronos.org + + + + + View details + + + + + Cancel + + + + + Approve + + + + + + + + + + + + + + + + Cronos Mainnet + + + + + Add + + + + + + + + + + + + Polygon Mainnet + + + + + Switch + + + + + + + + + + + + + + + + + + + +`; + +exports[`NetworkSwitcher View renders and dismisses network modal when pressing add button 2`] = ` + + + + + + + + + + + + + + + + + Unsupported buy Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + To buy crypto, you'll need to switch to a supported network + + + + + + + + + + + Ethereum Main Network + + + + + Switch + + + + + + + + + + + + Linea Main Network + + + + + Switch + + + + + + + + + + + + Cronos Mainnet + + + + + Add + + + + + + + + + + + + Polygon Mainnet + + + + + Switch + + + + + + + + + + + + + + + + + + + +`; + +exports[`NetworkSwitcher View renders correctly 1`] = ` + + + + + + + + + + + + + + + + + Unsupported buy Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + To buy crypto, you'll need to switch to a supported network + + + + + + + + + + + Ethereum Main Network + + + + + Switch + + + + + + + + + + + + Linea Main Network + + + + + Switch + + + + + + + + + + + + Cronos Mainnet + + + + + Add + + + + + + + + + + + + Polygon Mainnet + + + + + Switch + + + + + + + + + + + + + + + + + + + +`; + +exports[`NetworkSwitcher View renders correctly 2`] = ` + + + + + + + + + + + + + + + + + Unsupported sell Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + To sell crypto, you'll need to switch to a supported network + + + + + + + + + + + Ethereum Main Network + + + + + Switch + + + + + + + + + + + + Linea Main Network + + + + + Switch + + + + + + + + + + + + Cronos Mainnet + + + + + Add + + + + + + + + + + + + Polygon Mainnet + + + + + Switch + + + + + + + + + + + + + + + + + + + +`; + +exports[`NetworkSwitcher View renders correctly while loading 1`] = ` + + + + + + + + + + + + + + + + + Unsupported buy Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + To buy crypto, you'll need to switch to a supported network + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`NetworkSwitcher View renders correctly while loading 2`] = ` + + + + + + + + + + + + + + + + + Unsupported buy Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + To buy crypto, you'll need to switch to a supported network + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`NetworkSwitcher View renders correctly with errors 1`] = ` + + + + + + + + + + + + + + + + + Unsupported buy Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + Test Error for fetching networks + + + + + + Try again + + + + + + + + + + + + + + + + + + +`; + +exports[`NetworkSwitcher View renders correctly with errors 2`] = ` + + + + + + + + + + + + + + + + + Unsupported buy Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + Test Error for fetching networks details + + + + + + Try again + + + + + + + + + + + + + + + + + + +`; + +exports[`NetworkSwitcher View renders correctly with no data 1`] = ` + + + + + + + + + + + + + + + + + Unsupported buy Network + + + + + BNB Smart Chain + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + No supported networks found + + + + + + Try again + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Ramp/common/Views/NetworkSwitcher/index.ts b/app/components/UI/Ramp/common/Views/NetworkSwitcher/index.ts new file mode 100644 index 00000000000..008652f999c --- /dev/null +++ b/app/components/UI/Ramp/common/Views/NetworkSwitcher/index.ts @@ -0,0 +1 @@ +export { default } from './NetworkSwitcher'; diff --git a/app/components/UI/Ramp/common/Views/OrderDetails/OrderDetails.test.tsx b/app/components/UI/Ramp/common/Views/OrderDetails/OrderDetails.test.tsx new file mode 100644 index 00000000000..205931a1a38 --- /dev/null +++ b/app/components/UI/Ramp/common/Views/OrderDetails/OrderDetails.test.tsx @@ -0,0 +1,485 @@ +import React from 'react'; +import { processFiatOrder } from '../../../index'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { renderScreen } from '../../../../../../util/test/renderWithProvider'; +import OrderDetails from './OrderDetails'; +import initialBackgroundState from '../../../../../../util/test/initial-background-state.json'; +import { FiatOrder } from '../../../../../../reducers/fiatOrders'; +import { + FIAT_ORDER_PROVIDERS, + FIAT_ORDER_STATES, +} from '../../../../../../constants/on-ramp'; + +import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { RampSDK } from '../../sdk'; +import { PROVIDER_LINKS } from '../../types'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockSetNavigationOptions = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockDispatch = jest.fn(); + +jest.mock('../../../common/hooks/useAnalytics', () => () => mockTrackEvent); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + setOptions: mockSetNavigationOptions.mockImplementation( + actualReactNavigation.useNavigation().setOptions, + ), + }), + }; +}); + +type DeepPartial = { + [key in keyof BaseType]?: DeepPartial; +}; + +const mockOrder: DeepPartial = { + id: 'test-order-1', + account: '0x0', + network: '1', + cryptoAmount: '0.01231324', + orderType: OrderOrderTypeEnum.Buy, + state: FIAT_ORDER_STATES.PENDING, + createdAt: 1697242033399, + provider: FIAT_ORDER_PROVIDERS.AGGREGATOR, + cryptocurrency: 'ETH', + amount: '34.23', + currency: 'USD', + sellTxHash: '0x123', + lastTimeFetched: 0, + data: { + cryptoCurrency: { + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + provider: { + name: 'Test Provider', + }, + paymentMethod: { + id: 'test-payment-method-id', + }, + }, +}; + +const mockUseRampSDKInitialValues: DeepPartial = { + selectedPaymentMethodId: 'test-payment-method-id', + selectedRegion: { + currencies: ['/currencies/fiat/clp'], + emoji: '🇨🇱', + id: '/regions/cl', + name: 'Chile', + unsupported: false, + }, + selectedAsset: { symbol: 'TEST' }, + selectedFiatCurrencyId: '/test/fiat-currency', +}; + +const mockUseRampSDKValues: DeepPartial = { + ...mockUseRampSDKInitialValues, +}; + +jest.mock('../../../common/sdk', () => ({ + useRampSDK: () => mockUseRampSDKValues, +})); + +const mockUseParamsDefaultValues = { + orderId: mockOrder.id, + redirectToSendTransaction: false, +}; + +let mockUseParamsValues = { + ...mockUseParamsDefaultValues, +}; + +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), + useParams: () => mockUseParamsValues, +})); + +function mockGetUpdatedOrder(order: FiatOrder) { + return { + ...order, + lastTimeFetched: (order.lastTimeFetched || 0) + 100, + }; +} + +jest.mock('../../../index', () => ({ + processFiatOrder: jest.fn().mockImplementation((order, onSuccess) => { + const updatedOrder = mockGetUpdatedOrder(order); + if (onSuccess) { + onSuccess(updatedOrder); + } + Promise.resolve(); + }), +})); + +function render(Component: React.ComponentType, orders = [mockOrder]) { + return renderScreen( + Component, + { + name: Routes.RAMP.BUILD_QUOTE, + }, + { + state: { + engine: { + backgroundState: initialBackgroundState, + }, + fiatOrders: { + orders: orders as FiatOrder[], + }, + }, + }, + ); +} + +describe('OrderDetails', () => { + beforeEach(() => { + mockUseParamsValues = { + ...mockUseParamsDefaultValues, + }; + }); + + afterEach(() => { + mockTrackEvent.mockClear(); + (processFiatOrder as jest.Mock).mockClear(); + }); + + it('calls setOptions when rendering', async () => { + render(OrderDetails); + expect(mockSetNavigationOptions).toHaveBeenCalled(); + }); + + it('renders an empty screen layout if there is no order', async () => { + mockUseParamsValues = { + ...mockUseParamsDefaultValues, + orderId: 'invalid-id', + }; + render(OrderDetails); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('redirects to send transaction page when user is redirected back from a provider for a sell order', async () => { + const testOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.CREATED, + orderType: OrderOrderTypeEnum.Sell, + sellTxHash: undefined, + }; + mockUseParamsValues = { + ...mockUseParamsDefaultValues, + redirectToSendTransaction: true, + }; + render(OrderDetails, [testOrder]); + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.SEND_TRANSACTION, { + orderId: testOrder.id, + }); + }); + + it('renders a pending order', async () => { + render(OrderDetails); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders a completed order', async () => { + const completedOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.COMPLETED, + }; + render(OrderDetails, [completedOrder]); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders a cancelled order', async () => { + const cancelledOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.CANCELLED, + }; + render(OrderDetails, [cancelledOrder]); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders a failed order', async () => { + const failedOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.FAILED, + }; + render(OrderDetails, [failedOrder]); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('sends analytics events when an order is loaded', () => { + render(OrderDetails); + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "ONRAMP_PURCHASE_DETAILS_VIEWED", + Object { + "chain_id_destination": "1", + "currency_destination": "ETH", + "currency_source": "USD", + "order_type": "BUY", + "payment_method_id": "test-payment-method-id", + "provider_onramp": "Test Provider", + "status": "PENDING", + }, + ] + `); + + mockTrackEvent.mockReset(); + const testOrder = { + ...mockOrder, + orderType: OrderOrderTypeEnum.Sell, + }; + + render(OrderDetails, [testOrder]); + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "OFFRAMP_PURCHASE_DETAILS_VIEWED", + Object { + "chain_id_source": "1", + "currency_destination": "USD", + "currency_source": "ETH", + "order_type": "SELL", + "payment_method_id": "test-payment-method-id", + "provider_offramp": "Test Provider", + "status": "PENDING", + }, + ] + `); + }); + + it('navigates to buy flow when the user attempts to make another purchase', async () => { + const testOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.COMPLETED, + }; + + render(OrderDetails, [testOrder]); + expect( + screen.getByRole('button', { + name: 'Start a new order', + }), + ).toBeTruthy(); + + fireEvent.press(screen.getByRole('button', { name: 'Start a new order' })); + + expect(mockGoBack).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.BUY); + }); + + it('navigates to sell flow when the user attempts to make another purchase', async () => { + const testOrder = { + ...mockOrder, + orderType: OrderOrderTypeEnum.Sell, + state: FIAT_ORDER_STATES.COMPLETED, + }; + + render(OrderDetails, [testOrder]); + expect( + screen.getByRole('button', { + name: 'Start a new order', + }), + ).toBeTruthy(); + + fireEvent.press(screen.getByRole('button', { name: 'Start a new order' })); + + expect(mockGoBack).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.SELL); + }); + + it('renders a created order', async () => { + const createdOrder = { + ...mockOrder, + orderType: OrderOrderTypeEnum.Sell, + state: FIAT_ORDER_STATES.CREATED, + }; + await waitFor(() => render(OrderDetails, [createdOrder])); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders transacted orders that do not have timeDescriptionPending', async () => { + const createdOrder = { + ...mockOrder, + orderType: OrderOrderTypeEnum.Sell, + state: FIAT_ORDER_STATES.CREATED, + sellTxHash: '0x123', + }; + await waitFor(() => render(OrderDetails, [createdOrder])); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders transacted orders that have timeDescriptionPending', async () => { + const createdOrder = { + ...mockOrder, + orderType: OrderOrderTypeEnum.Sell, + state: FIAT_ORDER_STATES.CREATED, + sellTxHash: '0x123', + data: { + ...mockOrder.data, + timeDescriptionPending: 'test-time-description', + }, + }; + await waitFor(() => render(OrderDetails, [createdOrder])); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders non-transacted orders', async () => { + const createdOrder = { + ...mockOrder, + orderType: OrderOrderTypeEnum.Sell, + state: FIAT_ORDER_STATES.CREATED, + sellTxHash: undefined, + }; + await waitFor(() => render(OrderDetails, [createdOrder])); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('polls for a created order on load and dispatches an action to update', async () => { + const createdOrder = { + ...mockOrder, + orderType: OrderOrderTypeEnum.Sell, + state: FIAT_ORDER_STATES.CREATED, + }; + + await waitFor(() => render(OrderDetails, [createdOrder])); + + expect(processFiatOrder).toHaveBeenCalledWith( + createdOrder, + expect.any(Function), + expect.any(Function), + { forced: true }, + ); + + const updatedOrder = mockGetUpdatedOrder(createdOrder as FiatOrder); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'FIAT_UPDATE_ORDER', + payload: updatedOrder, + }); + }); + + it('renders an error screen if a CREATED order cannot be polled on load', async () => { + const createdOrder = { + ...mockOrder, + orderType: OrderOrderTypeEnum.Sell, + state: FIAT_ORDER_STATES.CREATED, + }; + (processFiatOrder as jest.Mock).mockImplementationOnce(() => { + throw new Error('An error occurred'); + }); + await waitFor(() => render(OrderDetails, [createdOrder])); + expect(screen.toJSON()).toMatchSnapshot(); + + await act(async () => { + fireEvent.press(screen.getByRole('button', { name: 'Try again' })); + }); + + expect(processFiatOrder).toHaveBeenCalledWith( + createdOrder, + expect.any(Function), + expect.any(Function), + { forced: true }, + ); + }); + + it('renders the support links if the provider has them', async () => { + const testOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.COMPLETED, + data: { + ...mockOrder.data, + provider: { + name: 'Test Provider', + links: [ + { + name: PROVIDER_LINKS.SUPPORT, + url: 'https://example.com', + }, + ], + }, + providerOrderLink: 'https://example.com', + }, + }; + render(OrderDetails, [testOrder]); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('tracks external link clicks', () => { + const testOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.COMPLETED, + data: { + ...mockOrder.data, + provider: { + name: 'Test Provider', + links: [ + { + name: PROVIDER_LINKS.SUPPORT, + url: 'https://example.com', + }, + ], + }, + providerOrderLink: 'https://example.com', + }, + }; + + render(OrderDetails, [testOrder]); + + fireEvent.press(screen.getByText('Contact Support')); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'ONRAMP_EXTERNAL_LINK_CLICKED', + { + location: 'Order Details Screen', + text: 'Etherscan Transaction', + url_domain: 'https://example.com', + }, + ); + + fireEvent.press(screen.getByText('View order status on Test Provider')); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'ONRAMP_EXTERNAL_LINK_CLICKED', + { + location: 'Order Details Screen', + text: 'Provider Order Tracking', + url_domain: 'https://example.com', + }, + ); + }); + + it('renders a "continue" button for created, non-transacted sell orders', async () => { + const testOrder = { + ...mockOrder, + state: FIAT_ORDER_STATES.CREATED, + orderType: OrderOrderTypeEnum.Sell, + sellTxHash: undefined, + }; + + render(OrderDetails, [testOrder]); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Continue this order' }), + ).toBeTruthy(); + }); + + fireEvent.press( + screen.getByRole('button', { name: 'Continue this order' }), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.SEND_TRANSACTION, { + orderId: testOrder.id, + }); + }); +}); diff --git a/app/components/UI/Ramp/common/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/common/Views/OrderDetails/OrderDetails.tsx new file mode 100644 index 00000000000..475e730d97c --- /dev/null +++ b/app/components/UI/Ramp/common/Views/OrderDetails/OrderDetails.tsx @@ -0,0 +1,265 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, RefreshControl } from 'react-native'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import { Order } from '@consensys/on-ramp-sdk'; +import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; +import { ScrollView } from 'react-native-gesture-handler'; +import useAnalytics from '../../hooks/useAnalytics'; +import useThunkDispatch from '../../../../../hooks/useThunkDispatch'; +import ScreenLayout from '../../components/ScreenLayout'; +import OrderDetail from '../../components/OrderDetails'; +import Row from '../../components/Row'; +import StyledButton from '../../../../StyledButton'; +import { + getOrderById, + updateFiatOrder, +} from '../../../../../../reducers/fiatOrders'; +import { strings } from '../../../../../../../locales/i18n'; +import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { processFiatOrder } from '../../../index'; +import { + createNavigationDetails, + useParams, +} from '../../../../../../util/navigation/navUtils'; +import { useTheme } from '../../../../../../util/theme'; +import Logger from '../../../../../../util/Logger'; +import { + selectNetworkConfigurations, + selectProviderConfig, +} from '../../../../../../selectors/networkController'; +import { RootState } from '../../../../../../reducers'; +import { FIAT_ORDER_STATES } from '../../../../../../constants/on-ramp'; +import ErrorView from '../../components/ErrorView'; + +interface OrderDetailsParams { + orderId?: string; + redirectToSendTransaction?: boolean; +} + +export const createOrderDetailsNavDetails = + createNavigationDetails(Routes.RAMP.ORDER_DETAILS); + +const OrderDetails = () => { + const trackEvent = useAnalytics(); + const providerConfig = useSelector(selectProviderConfig); + const networkConfigurations = useSelector(selectNetworkConfigurations); + const params = useParams(); + const order = useSelector((state: RootState) => + getOrderById(state, params.orderId), + ); + const [isLoading, setIsLoading] = useState( + order?.state === FIAT_ORDER_STATES.CREATED, + ); + const [error, setError] = useState(null); + const { colors } = useTheme(); + const navigation = useNavigation(); + const dispatch = useDispatch(); + const dispatchThunk = useThunkDispatch(); + + const [isRefreshing, setIsRefreshing] = useState(false); + + useEffect(() => { + navigation.setOptions( + getFiatOnRampAggNavbar( + navigation, + { + title: strings('fiat_on_ramp_aggregator.order_details.details_main'), + showCancel: false, + }, + colors, + ), + ); + }, [colors, navigation]); + + const navigateToSendTransaction = useCallback(() => { + if (order?.id) { + navigation.navigate(Routes.RAMP.SEND_TRANSACTION, { + orderId: order.id, + }); + } + }, [navigation, order?.id]); + + useEffect(() => { + if ( + order?.state === FIAT_ORDER_STATES.CREATED && + !order.sellTxHash && + params.redirectToSendTransaction + ) { + navigateToSendTransaction(); + } + }, [ + order?.state, + params.redirectToSendTransaction, + navigateToSendTransaction, + order?.sellTxHash, + ]); + + useEffect(() => { + if (order) { + const { data, state, cryptocurrency, orderType, currency, network } = + order; + + const { + paymentMethod: { id: paymentMethodId }, + provider: { name: providerName }, + } = data as Order; + + const payload = { + status: state, + payment_method_id: paymentMethodId, + order_type: orderType, + }; + if (order.orderType === OrderOrderTypeEnum.Buy) { + trackEvent('ONRAMP_PURCHASE_DETAILS_VIEWED', { + ...payload, + currency_destination: cryptocurrency, + currency_source: currency, + provider_onramp: providerName, + chain_id_destination: network, + }); + } else { + trackEvent('OFFRAMP_PURCHASE_DETAILS_VIEWED', { + ...payload, + currency_source: cryptocurrency, + currency_destination: currency, + provider_offramp: providerName, + chain_id_source: network, + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trackEvent]); + + const dispatchUpdateFiatOrder = useCallback( + (updatedOrder) => { + dispatch(updateFiatOrder(updatedOrder)); + }, + [dispatch], + ); + + const handleOnRefresh = useCallback(async () => { + if (!order) return; + try { + setError(null); + setIsRefreshing(true); + await processFiatOrder(order, dispatchUpdateFiatOrder, dispatchThunk, { + forced: true, + }); + } catch (fetchError) { + Logger.error(fetchError as Error, { + message: 'FiatOrders::OrderDetails error while processing order', + order, + }); + setError((fetchError as Error).message || 'An error as occurred'); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, [dispatchThunk, dispatchUpdateFiatOrder, order]); + + useEffect(() => { + if (order?.state === FIAT_ORDER_STATES.CREATED) { + handleOnRefresh(); + } + // only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleMakeAnotherPurchase = useCallback(() => { + navigation.goBack(); + navigation.navigate( + order?.orderType === OrderOrderTypeEnum.Buy + ? Routes.RAMP.BUY + : Routes.RAMP.SELL, + ); + }, [navigation, order?.orderType]); + + if (!order) { + return ; + } + + if (isLoading) { + return ( + + + + + + + + ); + } + + if (error) { + return ( + + + + + + ); + } + + return ( + + + } + > + + + + + + + + {order.orderType === OrderOrderTypeEnum.Sell && + !order.sellTxHash && + order.state === FIAT_ORDER_STATES.CREATED ? ( + + + {strings( + 'fiat_on_ramp_aggregator.order_details.continue_order', + )} + + + ) : null} + + {order.state !== FIAT_ORDER_STATES.CREATED && + order.state !== FIAT_ORDER_STATES.PENDING && ( + + {strings( + 'fiat_on_ramp_aggregator.order_details.start_new_order', + )} + + )} + + + + + ); +}; + +export default OrderDetails; diff --git a/app/components/UI/Ramp/common/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/common/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap new file mode 100644 index 00000000000..1f8b7b3d300 --- /dev/null +++ b/app/components/UI/Ramp/common/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -0,0 +1,18602 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OrderDetails renders a cancelled order 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + + Order Canceled + + + Something went wrong, and Test Provider was unable to complete your order. Please try again or with another provider. + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + + + + + Token Amount + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Amount + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Purchase Amount Total + + + + + ... + + + + + + + + + + + + + + + Start a new order + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders a completed order 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + +  + + + + Order Successful! + + + Your + + ETH + + is now available in your account + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + + + + + Token Amount + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Amount + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Purchase Amount Total + + + + + ... + + + + + + + + + + + + + + + Start a new order + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders a created order 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + +  + + + + + + Submitted + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + + + + + Token Quantity Sold + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Value + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Amount Received Total + + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders a failed order 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + + Order Failed + + + Something went wrong, and Test Provider was unable to complete your order. Please try again or with another provider. + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + + + + + Token Amount + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Amount + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Purchase Amount Total + + + + + ... + + + + + + + + + + + + + + + Start a new order + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders a pending order 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + +  + + + + + + Processing Order + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + + + + + Token Amount + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Amount + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Purchase Amount Total + + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders an empty screen layout if there is no order 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders an error screen if a CREATED order cannot be polled on load 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + An error occurred + + + + + + Try again + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders non-transacted orders 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + +  + + + + + + Order Pending + + + To continue your order, you'll need to select the button at the bottom of this page. + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + + + + + Token Quantity Sold + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Value + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Amount Received Total + + + + + ... + + + + + + + + + + + + + + + + Continue this order + + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders the support links if the provider has them 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + +  + + + + Order Successful! + + + Your + + ETH + + is now available in your account + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + View order status on Test Provider + + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + • + + + Contact Support + + + + + + + Token Amount + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Amount + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Purchase Amount Total + + + + + ... + + + + + + + + + + + + + + + Start a new order + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders transacted orders that do not have timeDescriptionPending 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + +  + + + + + + Submitted + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + + + + + Token Quantity Sold + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Value + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Amount Received Total + + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`OrderDetails renders transacted orders that have timeDescriptionPending 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Order Details + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + +  + + + + + + Submitted + + + test-time-description + + + + + + 0.01231 + + ETH + + + ... + USD + + + + + + + + + + + + + + + + ( + + 0x0 + + ) + + + + + + + + Order ID + + + + + + + + + + + + Date and Time + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider + + + + + + Token Quantity Sold + + + + + 0.01231 + + ETH + + + + + + + + + + Exchange Rate + + + + + ... + + + + + + + + + USD + + Value + + + + + ... + + + + + + + + + + Total Fees + + + + + ... + + + + + + + + + + Amount Received Total + + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Ramp/common/Views/OrderDetails/index.tsx b/app/components/UI/Ramp/common/Views/OrderDetails/index.tsx new file mode 100644 index 00000000000..b9447ca2d2b --- /dev/null +++ b/app/components/UI/Ramp/common/Views/OrderDetails/index.tsx @@ -0,0 +1 @@ +export { default } from './OrderDetails'; diff --git a/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.styles.ts b/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.styles.ts new file mode 100644 index 00000000000..52b5b4c1857 --- /dev/null +++ b/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.styles.ts @@ -0,0 +1,26 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from '../../../../../../util/theme/models'; + +const createStyles = (colors: Colors) => + StyleSheet.create({ + filters: { + flexDirection: 'row', + columnGap: 8, + alignItems: 'center', + marginVertical: 16, + marginHorizontal: 24, + }, + selectedFilter: { + borderWidth: 1, + borderColor: colors.primary.default, + }, + emptyMessage: { + textAlign: 'center', + }, + row: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: colors.border.muted, + }, + }); + +export default createStyles; diff --git a/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.test.tsx b/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.test.tsx new file mode 100644 index 00000000000..42a9fb12525 --- /dev/null +++ b/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.test.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import OrdersList from './OrdersList'; +import { + FIAT_ORDER_PROVIDERS, + FIAT_ORDER_STATES, +} from '../../../../../../constants/on-ramp'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { FiatOrder } from '../../../../../../reducers/fiatOrders'; +import initialBackgroundState from '../../../../../../util/test/initial-background-state.json'; +import { fireEvent, screen } from '@testing-library/react-native'; + +type DeepPartial = { + [key in keyof BaseType]?: DeepPartial; +}; + +const testOrders: DeepPartial[] = [ + { + id: 'test-order-1', + account: '0x0', + network: '1', + cryptoAmount: '0.01231324', + orderType: 'BUY', + state: FIAT_ORDER_STATES.COMPLETED, + createdAt: 1697242033399, + provider: FIAT_ORDER_PROVIDERS.AGGREGATOR, + cryptocurrency: 'ETH', + amount: '34.23', + currency: 'USD', + data: { + cryptoCurrency: { + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + provider: { + name: 'Test Provider', + }, + }, + }, + { + id: 'test-order-2', + account: '0x0', + network: '1', + cryptoAmount: '0.01231324', + orderType: 'SELL', + state: FIAT_ORDER_STATES.PENDING, + createdAt: 1697242033399, + provider: FIAT_ORDER_PROVIDERS.AGGREGATOR, + cryptocurrency: 'ETH', + amount: '34.23', + currency: 'USD', + data: { + cryptoCurrency: { + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + provider: { + name: 'Test Provider', + }, + }, + }, + { + id: 'test-order-3', + account: '0x0', + network: '1', + cryptoAmount: '0.01231324', + orderType: 'BUY', + state: FIAT_ORDER_STATES.PENDING, + createdAt: 1697242033399, + provider: FIAT_ORDER_PROVIDERS.AGGREGATOR, + cryptocurrency: 'ETH', + amount: '34.23', + currency: 'USD', + data: { + cryptoCurrency: { + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + provider: { + name: 'Test Provider', + }, + }, + }, + { + id: 'test-order-4', + account: '0x0', + network: '1', + orderType: 'BUY', + state: FIAT_ORDER_STATES.PENDING, + provider: FIAT_ORDER_PROVIDERS.AGGREGATOR, + cryptocurrency: 'ETH', + currency: 'USD', + data: { + cryptoCurrency: { + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + provider: { + name: 'Test Provider', + }, + }, + }, +]; + +function render(Component: React.ReactElement, orders = testOrders) { + return renderWithProvider(Component, { + state: { + engine: { + backgroundState: { + ...initialBackgroundState, + PreferencesController: { + selectedAddress: '0x0', + identities: { + '0x0': { + address: '0x0', + name: 'Account 1', + }, + }, + }, + NetworkController: { + network: '1', + providerConfig: { + ticker: 'ETH', + type: 'mainnet', + chainId: '1', + }, + }, + }, + }, + fiatOrders: { + orders: orders as FiatOrder[], + }, + }, + }); +} + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +describe('OrdersList', () => { + it('renders correctly', () => { + render(); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders buy only correctly when pressing buy filter', () => { + render(); + fireEvent.press(screen.getByRole('button', { name: 'Purchased' })); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders sell only correctly when pressing sell filter', () => { + render(); + fireEvent.press(screen.getByRole('button', { name: 'Sold' })); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders empty sell message', () => { + render( + , + [testOrders[0]], // a buy order, + ); + fireEvent.press(screen.getByRole('button', { name: 'Sold' })); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders empty buy message', () => { + render( + , + [testOrders[1]], // a sell order, + ); + fireEvent.press(screen.getByRole('button', { name: 'Purchased' })); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('resets filter to all after other filter was set', () => { + render(); + fireEvent.press(screen.getByRole('button', { name: 'Sold' })); + expect(screen.toJSON()).toMatchSnapshot(); + fireEvent.press(screen.getByRole('button', { name: 'All' })); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('navigates when pressing item', () => { + render(); + fireEvent.press(screen.getByRole('button', { name: 'Sold' })); + fireEvent.press(screen.getByRole('button', { name: /Sold ETH/ })); + expect(mockNavigate).toHaveBeenCalled(); + expect(mockNavigate.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "OrderDetails", + Object { + "orderId": "test-order-2", + }, + ], + ] + `); + }); +}); diff --git a/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.tsx b/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.tsx new file mode 100644 index 00000000000..7d52d9e2ce5 --- /dev/null +++ b/app/components/UI/Ramp/common/Views/OrdersList/OrdersList.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useState } from 'react'; +import { FlatList, TouchableHighlight } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; + +import { createOrderDetailsNavDetails } from '../OrderDetails/OrderDetails'; +import OrderListItem from '../../components/OrderListItem'; +import Row from '../../components/Row'; +import createStyles from './OrdersList.styles'; + +import Button, { + ButtonSize, + ButtonVariants, +} from '../../../../../../component-library/components/Buttons/Button'; +import { ButtonProps } from '../../../../../../component-library/components/Buttons/Button/Button.types'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; + +import { FIAT_ORDER_PROVIDERS } from '../../../../../../constants/on-ramp'; +import { FiatOrder, getOrders } from '../../../../../../reducers/fiatOrders'; +import { strings } from '../../../../../../../locales/i18n'; +import { useTheme } from '../../../../../../util/theme'; + +type filterType = 'ALL' | OrderOrderTypeEnum; + +interface FilterButtonProps extends Omit { + readonly selected?: boolean; +} + +function FilterButton({ selected = false, ...props }: FilterButtonProps) { + const { colors } = useTheme(); + const styles = createStyles(colors); + return ( +