diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 73d412210e0..a34af3d5b4c 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -50,7 +50,7 @@ import { FiatOnRampSDKProvider } from '../../UI/FiatOnRampAggregator/sdk'; import GetStarted from '../../../components/UI/FiatOnRampAggregator/Views/GetStarted'; import PaymentMethods from '../../UI/FiatOnRampAggregator/Views/PaymentMethods/PaymentMethods'; import AmountToBuy from '../../../components/UI/FiatOnRampAggregator/Views/AmountToBuy'; -import GetQuotes from '../../../components/UI/FiatOnRampAggregator/Views/GetQuotes'; +import Quotes from '../../../components/UI/FiatOnRampAggregator/Views/Quotes'; import CheckoutWebView from '../../UI/FiatOnRampAggregator/Views/Checkout'; import OnRampSettings from '../../UI/FiatOnRampAggregator/Views/Settings'; import OnrampAddActivationKey from '../../UI/FiatOnRampAggregator/Views/Settings/AddActivationKey'; @@ -547,8 +547,8 @@ const FiatOnRampAggregator = () => ( component={AmountToBuy} /> { const handleGetQuotePress = useCallback(() => { if (selectedAsset && currentFiatCurrency) { navigation.navigate( - ...createGetQuotesNavDetails({ + ...createQuotesNavDetails({ amount: amountNumber, asset: selectedAsset, fiatCurrency: currentFiatCurrency, diff --git a/app/components/UI/FiatOnRampAggregator/Views/Quotes/LoadingQuotes.tsx b/app/components/UI/FiatOnRampAggregator/Views/Quotes/LoadingQuotes.tsx new file mode 100644 index 00000000000..d053ff7a5f9 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/LoadingQuotes.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Row from '../../components/Row'; +import SkeletonQuote from '../../components/SkeletonQuote'; + +function LoadingQuotes() { + return ( + <> + + + + + + + + + + + ); +} +export default LoadingQuotes; diff --git a/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.constants.ts b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.constants.ts new file mode 100644 index 00000000000..86fee88a4f2 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.constants.ts @@ -0,0 +1,247 @@ +/* eslint-disable import/prefer-default-export */ + +import { QuoteError, QuoteResponse } from '@consensys/on-ramp-sdk'; +import { DeepPartial } from './Quotes.types'; + +export const mockQuotesData = [ + { + provider: { + id: '/providers/banxa-staging', + name: 'Banxa (Staging)', + description: + "Per Banxa: “Established from 2014, Banxa is the world's first publicly listed Financial technology platform, powering a world-leading fiat to crypto gateway solution for customers to buy, sell and trade digital assets. Banxa's payment infrastructure offers online payment services across multiple currencies, crypto, and payment types from card to local bank transfers. Banxa now supports over 130+ countries and more than 80 currencies.”", + hqAddress: '2/6 Gwynne St, Cremorne VIC 3121, Australia', + links: [ + { + name: 'Homepage', + url: 'https://banxa.com/', + }, + { + name: 'Terms of service', + url: 'https://banxa.com/wp-content/uploads/2022/10/Customer-Terms-and-Conditions-1-July-2022.pdf', + }, + ], + logos: { + light: + 'https://on-ramp.dev-api.cx.metamask.io/assets/providers/banxa_light.png', + dark: 'https://on-ramp.dev-api.cx.metamask.io/assets/providers/banxa_dark.png', + height: 24, + width: 65, + }, + features: { + buy: { + userAgent: null, + padCustomOrderId: false, + orderCustomId: '', + browser: 'APP_BROWSER', + orderCustomIdRequired: false, + orderCustomIdExpiration: null, + orderCustomIdSeparator: null, + orderCustomIdPrefixes: ['c-', ''], + supportedByBackend: true, + redirection: 'JSON_REDIRECTION', + }, + quotes: { + enabled: false, + supportedByBackend: false, + }, + }, + }, + crypto: { + id: '/currencies/crypto/1/eth', + idv2: '/currencies/crypto/1/0x0000000000000000000000000000000000000000', + network: { + active: true, + chainId: 1, + chainName: 'Ethereum Mainnet', + shortName: 'Ethereum', + }, + logo: 'https://token.metaswap.codefi.network/assets/nativeCurrencyLogos/ethereum.svg', + decimals: 18, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + }, + cryptoId: '/currencies/crypto/1/eth', + fiat: { + id: '/currencies/fiat/aud', + symbol: 'AUD', + name: 'Australian Dollar', + decimals: 2, + denomSymbol: '$', + }, + fiatId: '/currencies/fiat/aud', + networkFee: 0, + providerFee: 1.07, + extraFee: 0, + amountIn: 50, + amountOut: 0.017142, + paymentMethod: 'debit-credit-card', + receiver: '0x1', + isNativeApplePay: false, + exchangeRate: 2854.3927196359814, + error: false, + amountOutInFiat: 46.97353692000001, + }, + { + crypto: { + id: '/currencies/crypto/1/eth', + idv2: '/currencies/crypto/1/0x0000000000000000000000000000000000000000', + network: { + active: true, + chainId: 1, + chainName: 'Ethereum Mainnet', + shortName: 'Ethereum', + }, + logo: 'https://token.metaswap.codefi.network/assets/nativeCurrencyLogos/ethereum.svg', + decimals: 18, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + }, + fiat: { + id: '/currencies/fiat/aud', + symbol: 'AUD', + name: 'Australian Dollar', + decimals: 2, + denomSymbol: '$', + }, + amountIn: 50, + amountOut: 0.0162, + networkFee: 2.64, + providerFee: 1.8399999999999999, + provider: { + id: '/providers/moonpay-staging', + name: 'MoonPay (Staging)', + environmentType: 'STAGING', + description: + 'Per MoonPay: “MoonPay provides a smooth experience for converting between fiat currencies and cryptocurrencies. Easily top-up with ETH, BNB and more directly in your MetaMask wallet via MoonPay using all major payment methods including debit and credit card, local bank transfers, Apple Pay, Google Pay, and Samsung Pay. MoonPay is active in more than 160 countries and is trusted by 250+ leading wallets, websites, and applications.”', + hqAddress: '8 The Green, Dover, DE, 19901, USA', + links: [ + { + name: 'Homepage', + url: 'https://www.moonpay.com/', + }, + { + name: 'Privacy Policy', + url: 'https://www.moonpay.com/legal/privacy_policy', + }, + { + name: 'Support', + url: 'https://support.moonpay.com/hc/en-gb/categories/360001595097-Customer-Support-Help-Center', + }, + ], + logos: { + light: + 'https://on-ramp.dev-api.cx.metamask.io/assets/providers/moonpay_light.png', + dark: 'https://on-ramp.dev-api.cx.metamask.io/assets/providers/moonpay_dark.png', + height: 24, + width: 88, + }, + features: { + buy: { + enabled: true, + userAgent: null, + padCustomOrderId: false, + orderCustomId: 'GUID', + orderCustomIdRequired: false, + orderCustomIdPrefixes: ['c-'], + browser: 'APP_BROWSER', + supportedByBackend: true, + redirection: 'JSON_REDIRECTION', + }, + quotes: { + enabled: true, + supportedByBackend: false, + }, + sell: { + enabled: true, + }, + sellQuotes: { + enabled: true, + }, + }, + }, + exchangeRate: 2809.8765432098767, + error: false, + amountOutInFiat: 44.392212, + }, + { + crypto: { + id: '/currencies/crypto/1/eth', + idv2: '/currencies/crypto/1/0x0000000000000000000000000000000000000000', + network: { + active: true, + chainId: 1, + chainName: 'Ethereum Mainnet', + shortName: 'Ethereum', + }, + logo: 'https://token.metaswap.codefi.network/assets/nativeCurrencyLogos/ethereum.svg', + decimals: 18, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + }, + fiat: { + id: '/currencies/fiat/aud', + symbol: 'AUD', + name: 'Australian Dollar', + decimals: 2, + denomSymbol: '$', + }, + amountIn: 50, + amountOut: 0.01590613, + providerFee: 5.76, + networkFee: 0.5, + provider: { + id: '/providers/transak-staging', + name: 'Transak (Staging)', + environmentType: 'STAGING', + description: + 'Per Transak: “The fastest and securest way to buy 100+ cryptocurrencies on 75+ blockchains. Pay via Apple Pay, UPI, bank transfer or use your debit or credit card. Trusted by 2+ million global users. Transak empowers wallets, gaming, DeFi, NFTs, Exchanges, and DAOs across 125+ countries.”', + hqAddress: + '35 Shearing Street, Bury St. Edmunds, IP32 6FE, United Kingdom', + links: [ + { + name: 'Homepage', + url: 'https://www.transak.com/', + }, + { + name: 'Privacy Policy', + url: 'https://www.notion.so/Privacy-Policy-e7f23fb15ece4cf5b0586f9629e08b3f', + }, + { + name: 'Support', + url: 'https://support.transak.com/hc/en-us', + }, + ], + logos: { + light: + 'https://on-ramp.dev-api.cx.metamask.io/assets/providers/transak_light.png', + dark: 'https://on-ramp.dev-api.cx.metamask.io/assets/providers/transak_dark.png', + height: 24, + width: 90, + }, + features: { + buy: { + enabled: true, + userAgent: null, + padCustomOrderId: false, + orderCustomId: '', + orderCustomIdRequired: false, + orderCustomIdPrefixes: [], + browser: 'APP_BROWSER', + supportedByBackend: false, + redirection: 'HTTP_REDIRECTION', + }, + quotes: { + enabled: true, + supportedByBackend: false, + }, + }, + }, + exchangeRate: 2749.8832211229255, + error: true, + amountOutInFiat: 43.586931793800005, + }, +] as unknown as (DeepPartial | DeepPartial)[]; diff --git a/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.styles.ts b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.styles.ts new file mode 100644 index 00000000000..40315434da9 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.styles.ts @@ -0,0 +1,40 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + return StyleSheet.create({ + timerWrapper: { + backgroundColor: colors.background.alternative, + borderRadius: 20, + marginBottom: 8, + paddingVertical: 4, + paddingHorizontal: 15, + flexDirection: 'row', + alignItems: 'center', + }, + timer: { + fontVariant: ['tabular-nums'], + }, + timerHiglight: { + color: colors.error.default, + }, + topBorder: { + height: 1, + width: '100%', + backgroundColor: colors.border.default, + }, + withoutTopPadding: { + paddingTop: 0, + }, + withoutTopMargin: { + marginTop: 0, + }, + withoutVerticalPadding: { + paddingVertical: 0, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.test.tsx b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.test.tsx new file mode 100644 index 00000000000..2d3a39c42d3 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.test.tsx @@ -0,0 +1,576 @@ +import React from 'react'; +import { cloneDeep } from 'lodash'; +import { + ProviderBuyFeatureBrowserEnum, + QuoteError, + QuoteResponse, +} from '@consensys/on-ramp-sdk'; +import { + act, + fireEvent, + screen, + render as renderComponent, +} from '@testing-library/react-native'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; + +import Quotes, { QuotesParams } from './Quotes'; +import { mockQuotesData } from './Quotes.constants'; +import type { DeepPartial } from './Quotes.types'; +import Timer from './Timer'; +import LoadingQuotes from './LoadingQuotes'; + +import { OnRampSDK } from '../../sdk'; +import useQuotes from '../../hooks/useQuotes'; + +import Routes from '../../../../../constants/navigation/Routes'; + +function render(Component: React.ComponentType) { + return renderScreen( + Component, + { + name: Routes.FIAT_ON_RAMP_AGGREGATOR.QUOTES, + }, + { + state: { + engine: { + backgroundState: { + PreferencesController: {}, + NetworkController: { + providerConfig: { + type: 'mainnet', + chainId: 1, + }, + }, + }, + }, + }, + }, + ); +} + +jest.unmock('react-redux'); + +const mockSetOptions = jest.fn(); +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockReset = jest.fn(); +const mockPop = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockRenderInAppBrowser = 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 mockuseFiatOnRampSDKInitialValues: Partial = { + selectedPaymentMethodId: '/payment-methods/test-payment-method', + selectedChainId: '1', + appConfig: { + POLLING_CYCLES: 2, + POLLING_INTERVAL: 10000, + POLLING_INTERVAL_HIGHLIGHT: 1000, + }, + callbackBaseUrl: '', + sdkError: undefined, +}; + +let mockUseFiatOnRampSDKValues: DeepPartial = { + ...mockuseFiatOnRampSDKInitialValues, +}; + +jest.mock('../../sdk', () => ({ + ...jest.requireActual('../../sdk'), + useFiatOnRampSDK: () => mockUseFiatOnRampSDKValues, +})); + +jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); +jest.mock('../../hooks/useInAppBrowser', () => () => mockRenderInAppBrowser); + +const mockuseParamsInitialValues: DeepPartial = { + amount: 50, + asset: { + symbol: 'ETH', + }, + fiatCurrency: { + symbol: 'USD', + }, +}; + +let mockUseParamsValues = { + ...mockuseParamsInitialValues, +}; + +jest.mock('../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../util/navigation/navUtils'), + useParams: () => mockUseParamsValues, +})); + +const mockQueryGetQuotes = jest.fn(); + +const mockuseQuotesInitialValues: Partial> = { + data: mockQuotesData as (QuoteResponse | QuoteError)[], + isFetching: false, + error: null, + query: mockQueryGetQuotes, +}; + +let mockuseQuotesValues: Partial> = { + ...mockuseQuotesInitialValues, +}; + +jest.mock('../../hooks/useQuotes', () => jest.fn(() => mockuseQuotesValues)); + +describe('Quotes', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + beforeEach(() => { + // Quotes view uses a timer to poll for quotes, we use fake timers + // to have control over the timer with jest timer methods + // Reference: https://jestjs.io/docs/timer-mocks + jest.useFakeTimers(); + mockUseFiatOnRampSDKValues = { + ...mockuseFiatOnRampSDKInitialValues, + }; + mockUseParamsValues = { + ...mockuseParamsInitialValues, + }; + mockuseQuotesValues = { + ...mockuseQuotesInitialValues, + }; + }); + + it('calls setOptions when rendering', async () => { + mockuseQuotesValues = { + ...mockuseQuotesInitialValues, + isFetching: true, + data: null, + }; + render(Quotes); + expect(mockSetOptions).toBeCalledTimes(1); + }); + + it('navigates and tracks event on cancel button press', async () => { + render(Quotes); + fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + expect(mockPop).toHaveBeenCalled(); + expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', { + chain_id_destination: '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, + isFetching: true, + data: null, + }; + render(Quotes); + const fetchingQuotesText = screen.getByText('Fetching quotes'); + expect(fetchingQuotesText).toBeTruthy(); + expect(screen.toJSON()).toMatchSnapshot(); + }); + + it('renders correctly after animation without quotes', async () => { + mockuseQuotesValues = { + ...mockuseQuotesInitialValues, + data: [], + }; + render(Quotes); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + expect(screen.toJSON()).toMatchSnapshot(); + expect(screen.getByText('No providers available')).toBeTruthy(); + act(() => { + jest.useRealTimers(); + }); + }); + + it('renders correctly after animation with quotes', async () => { + render(Quotes); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + expect(screen.toJSON()).toMatchSnapshot(); + act(() => { + jest.useRealTimers(); + }); + }); + + it('navigates and tracks events when pressing buy button with 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; + 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, + data: mockData as (QuoteResponse | QuoteError)[], + }; + render(Quotes); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + const quoteToSelect = screen.getByLabelText(mockQuoteProviderName); + fireEvent.press(quoteToSelect); + + const quoteBuyButton = screen.getByRole('button', { + name: `Buy with ${mockQuoteProviderName}`, + }); + + await act(async () => { + fireEvent.press(quoteBuyButton); + }); + + 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', + }, + ); + + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "ONRAMP_PROVIDER_SELECTED", + Object { + "chain_id_destination": "1", + "crypto_out": 0.0162, + "currency_destination": "ETH", + "currency_source": "USD", + "exchange_rate": 2809.8765432098767, + "gas_fee": 2.64, + "payment_method_id": "/payment-methods/test-payment-method", + "processing_fee": 1.8399999999999999, + "provider_onramp": "MoonPay (Staging)", + "quote_position": 2, + "refresh_count": 1, + "results_count": 2, + "total_fee": 4.48, + }, + ] + `); + }); + + 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(); + }); + + const quoteToSelect = screen.getByLabelText(mockQuoteProviderName); + fireEvent.press(quoteToSelect); + + const quoteBuyButton = screen.getByRole('button', { + name: `Buy with ${mockQuoteProviderName}`, + }); + + await act(async () => { + fireEvent.press(quoteBuyButton); + }); + + expect(mockRenderInAppBrowser).toBeCalledWith( + mockedBuyAction, + mockedQuote.provider, + mockedQuote.amountIn, + mockedQuote.fiat?.symbol, + ); + + expect(mockTrackEvent.mock.lastCall).toMatchInlineSnapshot(` + Array [ + "ONRAMP_PROVIDER_SELECTED", + Object { + "chain_id_destination": "1", + "crypto_out": 0.0162, + "currency_destination": "ETH", + "currency_source": "USD", + "exchange_rate": 2809.8765432098767, + "gas_fee": 2.64, + "payment_method_id": "/payment-methods/test-payment-method", + "processing_fee": 1.8399999999999999, + "provider_onramp": "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(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + + const mockQuoteProvider = mockQuotesData[0] + .provider as QuoteResponse['provider']; + + const descriptionNotFound = screen.queryByText( + mockQuoteProvider.description, + ); + expect(descriptionNotFound).toBeFalsy(); + + const quoteProviderLogo = screen.getByLabelText( + `${mockQuoteProvider.name} logo`, + ); + + fireEvent.press(quoteProviderLogo); + + const description = screen.queryByText(mockQuoteProvider.description); + expect(description).toBeTruthy(); + + act(() => { + jest.useRealTimers(); + }); + }); + + it('calls fetch quotes after quotes expire', async () => { + render(Quotes); + act(() => { + jest.advanceTimersByTime(15000); + jest.clearAllTimers(); + }); + expect(mockQueryGetQuotes).toHaveBeenCalledTimes(1); + act(() => { + jest.useRealTimers(); + }); + }); + + it('renders "quotes expire" text in the last cycle', async () => { + render(Quotes); + act(() => { + jest.advanceTimersByTime(15000); + jest.clearAllTimers(); + }); + expect(screen.getByText('Quotes expire in', { exact: false })).toBeTruthy(); + act(() => { + jest.useRealTimers(); + }); + }); + + it('renders quotes expired screen', async () => { + mockUseFiatOnRampSDKValues = { + ...mockuseFiatOnRampSDKInitialValues, + appConfig: { + ...mockuseFiatOnRampSDKInitialValues.appConfig, + POLLING_CYCLES: 0, + }, + }; + render(Quotes); + expect(screen.toJSON()).toMatchSnapshot(); + expect(screen.getByText('Quotes timeout', { exact: false })).toBeTruthy(); + fireEvent.press(screen.getByRole('button', { name: 'Get new quotes' })); + expect(mockQueryGetQuotes).toHaveBeenCalledTimes(1); + act(() => { + jest.useRealTimers(); + }); + }); + + it('calls track event on quotes received and quote error', async () => { + render(Quotes); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + expect(mockTrackEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "ONRAMP_QUOTES_RECEIVED", + Object { + "amount": 50, + "average_crypto_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_destination": "1", + "currency_destination": "ETH", + "currency_source": "USD", + "payment_method_id": "/payment-methods/test-payment-method", + "provider_onramp_first": "Banxa (Staging)", + "provider_onramp_last": "MoonPay (Staging)", + "provider_onramp_list": Array [ + "Banxa (Staging)", + "MoonPay (Staging)", + ], + "refresh_count": 1, + "results_count": 2, + }, + ], + Array [ + "ONRAMP_QUOTE_ERROR", + Object { + "amount": 50, + "chain_id_destination": "1", + "currency_destination": "ETH", + "currency_source": "USD", + "error_message": undefined, + "payment_method_id": "/payment-methods/test-payment-method", + "provider_onramp": "Transak (Staging)", + }, + ], + ] + `); + act(() => { + jest.useRealTimers(); + }); + }); + + it('renders correctly with sdkError', async () => { + mockUseFiatOnRampSDKValues = { + ...mockuseFiatOnRampSDKInitialValues, + sdkError: new Error('Example SDK Error'), + }; + render(Quotes); + expect(screen.toJSON()).toMatchSnapshot(); + expect(screen.getByText('Example SDK Error')).toBeTruthy(); + act(() => { + jest.useRealTimers(); + }); + }); + + it('navigates to home when clicking sdKError button', async () => { + mockUseFiatOnRampSDKValues = { + ...mockuseFiatOnRampSDKInitialValues, + sdkError: new Error('Example SDK Error'), + }; + render(Quotes); + fireEvent.press( + screen.getByRole('button', { name: 'Return to Home Screen' }), + ); + expect(mockPop).toBeCalledTimes(1); + act(() => { + jest.useRealTimers(); + }); + }); + + it('renders correctly when fetching quotes errors', async () => { + mockuseQuotesValues = { + ...mockuseQuotesInitialValues, + error: 'Test Error', + }; + render(Quotes); + expect(screen.toJSON()).toMatchSnapshot(); + act(() => { + jest.useRealTimers(); + }); + }); + + it('fetches quotes again when pressing button after fetching quotes errors', async () => { + mockuseQuotesValues = { + ...mockuseQuotesInitialValues, + error: 'Test Error', + }; + render(Quotes); + fireEvent.press(screen.getByRole('button', { name: 'Try again' })); + expect(mockQueryGetQuotes).toBeCalledTimes(1); + act(() => { + jest.useRealTimers(); + }); + }); +}); + +describe('LoadingQuotes component', () => { + it('renders correctly', () => { + renderComponent(); + expect(screen.toJSON()).toMatchSnapshot(); + }); +}); + +describe('Timer component', () => { + it.each` + isFetchingQuotes | pollingCyclesLeft | remainingTime + ${true} | ${1} | ${15000} + ${true} | ${1} | ${5000} + ${true} | ${0} | ${15000} + ${true} | ${0} | ${5000} + ${false} | ${1} | ${15000} + ${false} | ${1} | ${5000} + ${false} | ${0} | ${15000} + ${false} | ${0} | ${5000} + ${false} | ${0} | ${20000} + `( + 'renders correctly with isFetchingQuotes=$isFetchingQuotes, pollingCyclesLeft=$pollingCyclesLeft, remainingTime=$remainingTime', + ({ + isFetchingQuotes, + pollingCyclesLeft, + remainingTime, + }: { + isFetchingQuotes: boolean; + pollingCyclesLeft: number; + remainingTime: number; + }) => { + renderComponent( + , + ); + expect(screen.toJSON()).toMatchSnapshot(); + }, + ); +}); diff --git a/app/components/UI/FiatOnRampAggregator/Views/GetQuotes.tsx b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.tsx similarity index 53% rename from app/components/UI/FiatOnRampAggregator/Views/GetQuotes.tsx rename to app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.tsx index 40f9769ec15..f3a954b3811 100644 --- a/app/components/UI/FiatOnRampAggregator/Views/GetQuotes.tsx +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.tsx @@ -1,241 +1,90 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - View, - StyleSheet, - ActivityIndicator, - StyleProp, - ViewStyle, -} from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Animated, { - Extrapolate, - interpolate, - useAnimatedScrollHandler, - useAnimatedStyle, - useSharedValue, -} from 'react-native-reanimated'; -import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; -import { QuoteResponse, Provider } from '@consensys/on-ramp-sdk'; import { CryptoCurrency, FiatCurrency, ProviderBuyFeatureBrowserEnum, -} from '@consensys/on-ramp-sdk/dist/API'; - -import { useFiatOnRampSDK } from '../sdk'; -import useSDKMethod from '../hooks/useSDKMethod'; -import useAnalytics from '../hooks/useAnalytics'; -import useInAppBrowser from '../hooks/useInAppBrowser'; - -import ScreenLayout from '../components/ScreenLayout'; -import LoadingAnimation from '../components/LoadingAnimation'; -import Quote from '../components/Quote'; -import ErrorView from '../components/ErrorView'; -import ErrorViewWithReporting from '../components/ErrorViewWithReporting'; -import InfoAlert from '../components/InfoAlert'; -import SkeletonText from '../components/SkeletonText'; -import Box from '../components/Box'; -import Text from '../../../Base/Text'; -import StyledButton from '../../StyledButton'; -import BaseListItem from '../../../Base/ListItem'; -import { getFiatOnRampAggNavbar } from '../../Navbar'; - -import useInterval from '../../../hooks/useInterval'; -import { strings } from '../../../../../locales/i18n'; -import Device from '../../../../util/device'; -import { useTheme } from '../../../../util/theme'; -import { Colors } from '../../../../util/theme/models'; -import { PROVIDER_LINKS } from '../types'; - + QuoteError, + QuoteResponse, +} 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 useQuotes from '../../hooks/useQuotes'; +import { useFiatOnRampSDK } from '../../sdk'; +import { useStyles } from '../../../../../component-library/hooks'; import { createNavigationDetails, useParams, -} from '../../../../util/navigation/navUtils'; -import Routes from '../../../../constants/navigation/Routes'; -import { createCheckoutNavDetails } from './Checkout'; - -// TODO: Convert into typescript and correctly type -const ListItem = BaseListItem as any; +} 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'; +import Animated, { + Extrapolate, + interpolate, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; +import useInAppBrowser from '../../hooks/useInAppBrowser'; +import { createCheckoutNavDetails } from '../Checkout'; +import { PROVIDER_LINKS } from '../../types'; +import Logger from '../../../../../util/Logger'; +import Timer from './Timer'; -interface GetQuotesParams { +export interface QuotesParams { amount: number; asset: CryptoCurrency; fiatCurrency: FiatCurrency; } -export const createGetQuotesNavDetails = - createNavigationDetails( - Routes.FIAT_ON_RAMP_AGGREGATOR.GET_QUOTES, - ); - -const createStyles = (colors: Colors) => - StyleSheet.create({ - row: { - marginVertical: 8, - }, - topBorder: { - height: 1, - width: '100%', - backgroundColor: colors.border.default, - }, - timerWrapper: { - backgroundColor: colors.background.alternative, - borderRadius: 20, - marginBottom: 8, - paddingVertical: 4, - paddingHorizontal: 15, - flexDirection: 'row', - alignItems: 'center', - }, - timer: { - fontVariant: ['tabular-nums'], - }, - timerHiglight: { - color: colors.error.default, - }, - errorContent: { - paddingHorizontal: 20, - alignItems: 'center', - }, - errorViewContent: { - flex: 1, - marginHorizontal: Device.isSmallDevice() ? 20 : 55, - justifyContent: 'center', - }, - errorTitle: { - fontSize: 24, - marginVertical: 10, - }, - errorText: { - fontSize: 14, - }, - errorIcon: { - fontSize: 46, - marginVertical: 4, - color: colors.error.default, - }, - expiredIcon: { - color: colors.primary.default, - }, - screen: { - flexGrow: 1, - justifyContent: 'space-between', - }, - bottomSection: { - marginBottom: 6, - alignItems: 'stretch', - paddingHorizontal: 20, - }, - ctaButton: { - marginBottom: 30, - }, - withoutTopPadding: { - paddingTop: 0, - }, - withoutTopMargin: { - marginTop: 0, - }, - withoutVerticalPadding: { - paddingVertical: 0, - }, - }); +export const createQuotesNavDetails = createNavigationDetails( + Routes.FIAT_ON_RAMP_AGGREGATOR.QUOTES, +); -const SkeletonQuote = ({ - collapsed, - style, -}: { - collapsed?: boolean; - style?: StyleProp; -}) => { - const { colors } = useTheme(); - const styles = createStyles(colors); - return ( - - - - - - - - - - - - {!collapsed && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - )} - - ); -}; - -const sortByAmountOut = (a: QuoteResponse, b: QuoteResponse) => { - if (a.amountOut && b.amountOut) { - return b.amountOut - a.amountOut; - } - return 0; -}; - -const GetQuotes = () => { +function Quotes() { + const navigation = useNavigation(); + const trackEvent = useAnalytics(); + const params = useParams(); const { selectedPaymentMethodId, - selectedRegion, - selectedAsset, - selectedAddress, - selectedFiatCurrencyId, selectedChainId, appConfig, callbackBaseUrl, sdkError, } = useFiatOnRampSDK(); - const renderInAppBrowser = useInAppBrowser(); - const { colors } = useTheme(); - const styles = createStyles(colors); - - const params = useParams(); - const navigation = useNavigation(); - const trackEvent = useAnalytics(); const [isLoading, setIsLoading] = useState(true); - const [isQuoteLoading, setIsQuoteLoading] = useState(false); const [shouldFinishAnimation, setShouldFinishAnimation] = useState(false); + const [firstFetchCompleted, setFirstFetchCompleted] = useState(false); const [isInPolling, setIsInPolling] = useState(false); + const [isQuoteLoading, setIsQuoteLoading] = useState(false); + const [showProviderInfo, setShowProviderInfo] = useState(false); + const [selectedProviderInfo, setSelectedProviderInfo] = + useState(null); + const [providerId, setProviderId] = useState(null); const [pollingCyclesLeft, setPollingCyclesLeft] = useState( appConfig.POLLING_CYCLES - 1, ); const [remainingTime, setRemainingTime] = useState( appConfig.POLLING_INTERVAL, ); - const [showProviderInfo, setShowProviderInfo] = useState(false); - const [selectedProviderInfo, setSelectedProviderInfo] = - useState(null); - const [providerId, setProviderId] = useState(null); + const { styles, theme } = useStyles(styleSheet, {}); const scrollOffsetY = useSharedValue(0); const scrollHandler = useAnimatedScrollHandler((event) => { @@ -251,24 +100,15 @@ const GetQuotes = () => { return { opacity: value }; }); - const [ - { data: quotes, isFetching: isFetchingQuotes, error: ErrorFetchingQuotes }, - fetchQuotes, - ] = useSDKMethod( - 'getQuotes', - selectedRegion?.id, - selectedPaymentMethodId, - selectedAsset?.id, - selectedFiatCurrencyId, - params.amount, - selectedAddress, - ); - - const filteredQuotes: QuoteResponse[] = useMemo( - () => - (quotes || []) - .filter((quote): quote is QuoteResponse => !quote.error) - .sort(sortByAmountOut), + const { + data: quotes, + isFetching: isFetchingQuotes, + error: ErrorFetchingQuotes, + query: fetchQuotes, + } = useQuotes(params.amount); + + const filteredQuotes = useMemo( + () => quotes?.filter((quote): quote is QuoteResponse => !quote.error) ?? [], [quotes], ); @@ -280,7 +120,125 @@ const GetQuotes = () => { }); }, [filteredQuotes.length, selectedChainId, trackEvent]); - // we only activate this interval polling once the first fetch of quotes is successfull + const handleFetchQuotes = useCallback(() => { + setIsLoading(true); + setIsInPolling(true); + setPollingCyclesLeft(appConfig.POLLING_CYCLES - 1); + setRemainingTime(appConfig.POLLING_INTERVAL); + fetchQuotes(); + trackEvent('ONRAMP_QUOTES_REQUESTED', { + currency_source: params.fiatCurrency?.symbol, + currency_destination: params.asset?.symbol, + payment_method_id: selectedPaymentMethodId as string, + chain_id_destination: selectedChainId, + amount: params.amount, + location: 'Quotes Screen', + }); + }, [ + appConfig.POLLING_CYCLES, + appConfig.POLLING_INTERVAL, + fetchQuotes, + params, + selectedChainId, + selectedPaymentMethodId, + trackEvent, + ]); + + const handleOnQuotePress = useCallback((quote: QuoteResponse) => { + 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, + }); + } + }, + [trackEvent], + ); + + const handleOnPressBuy = useCallback( + async (quote: QuoteResponse, index) => { + if (!quote?.buy) { + return; + } + try { + setIsQuoteLoading(true); + + const totalFee = + (quote.networkFee ?? 0) + + (quote.providerFee ?? 0) + + (quote.extraFee ?? 0); + + trackEvent('ONRAMP_PROVIDER_SELECTED', { + provider_onramp: quote.provider.name, + 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), + }); + + const buyAction = await quote.buy(); + if ( + buyAction.browser === ProviderBuyFeatureBrowserEnum.InAppOsBrowser + ) { + await renderInAppBrowser( + buyAction, + quote.provider, + quote.amountIn as number, + quote.fiat?.symbol, + ); + } else if ( + buyAction.browser === ProviderBuyFeatureBrowserEnum.AppBrowser + ) { + const { url, orderId: customOrderId } = await buyAction.createWidget( + callbackBaseUrl, + ); + navigation.navigate( + ...createCheckoutNavDetails({ + provider: quote.provider, + url, + customOrderId, + }), + ); + } else { + throw new Error('Unsupported browser type: ' + buyAction.browser); + } + } catch (error) { + Logger.error(error as Error, { + message: 'FiatOnRampAgg::Quotes error onPressBuy', + }); + } finally { + setIsQuoteLoading(false); + } + }, + [ + appConfig.POLLING_CYCLES, + callbackBaseUrl, + filteredQuotes.length, + navigation, + params, + pollingCyclesLeft, + renderInAppBrowser, + selectedChainId, + selectedPaymentMethodId, + trackEvent, + ], + ); + useInterval( () => { setRemainingTime((prevRemainingTime) => { @@ -288,7 +246,6 @@ const GetQuotes = () => { if (newRemainingTime <= 0) { setPollingCyclesLeft((cycles) => cycles - 1); - // we never fetch data if we run out of remaining polling cycles if (pollingCyclesLeft > 0) { setProviderId(null); fetchQuotes(); @@ -300,10 +257,9 @@ const GetQuotes = () => { : appConfig.POLLING_INTERVAL; }); }, - isInPolling ? 1000 : null, + isInPolling && !isFetchingQuotes ? 1000 : null, ); - // Listen to the event of first fetch completed useEffect(() => { if ( !firstFetchCompleted && @@ -324,7 +280,6 @@ const GetQuotes = () => { isInPolling, ]); - // The moment we have consumed all of our polling cycles, we need to stop fetching new quotes and clear the interval useEffect(() => { if (pollingCyclesLeft < 0 || ErrorFetchingQuotes) { setIsInPolling(false); @@ -338,11 +293,11 @@ const GetQuotes = () => { getFiatOnRampAggNavbar( navigation, { title: strings('fiat_on_ramp_aggregator.select_a_quote') }, - colors, + theme.colors, handleCancelPress, ), ); - }, [navigation, colors, handleCancelPress]); + }, [navigation, theme.colors, handleCancelPress]); useEffect(() => { if (isFetchingQuotes) return; @@ -350,31 +305,26 @@ const GetQuotes = () => { }, [isFetchingQuotes]); useEffect(() => { - if ( - shouldFinishAnimation && - quotes && - !isFetchingQuotes && - pollingCyclesLeft >= 0 - ) { - const quotesWithoutError = quotes - .filter((quote): quote is QuoteResponse => !quote.error) - .sort(sortByAmountOut); + if (quotes && !isFetchingQuotes && pollingCyclesLeft >= 0) { + const quotesWithoutError = quotes.filter( + (quote): quote is QuoteResponse => !quote.error, + ); if (quotesWithoutError.length > 0) { const totals = quotesWithoutError.reduce( (acc, curr) => { const totalFee = acc.totalFee + - ((curr?.networkFee || 0) + - (curr?.providerFee || 0) + - (curr?.extraFee || 0)); + ((curr.networkFee ?? 0) + + (curr.providerFee ?? 0) + + (curr.extraFee ?? 0)); return { - amountOut: acc.amountOut + (curr?.amountOut || 0), + amountOut: acc.amountOut + (curr.amountOut ?? 0), totalFee, - totalGasFee: acc.totalGasFee + (curr?.networkFee || 0), + totalGasFee: acc.totalGasFee + (curr.networkFee ?? 0), totalProcessingFee: - acc.totalProcessingFee + (curr?.providerFee || 0), + acc.totalProcessingFee + (curr.providerFee ?? 0), feeAmountRatio: - acc.feeAmountRatio + totalFee / (curr?.amountOut || 0), + acc.feeAmountRatio + totalFee / (curr.amountOut ?? 0), }; }, { @@ -412,21 +362,19 @@ const GetQuotes = () => { }); } - if (quotes.length > quotesWithoutError.length) { - quotes - .filter(({ error }) => Boolean(error)) - .forEach((quote) => - trackEvent('ONRAMP_QUOTE_ERROR', { - provider_onramp: quote.provider.name, - currency_source: params.fiatCurrency?.symbol, - currency_destination: params.asset?.symbol, - payment_method_id: selectedPaymentMethodId as string, - chain_id_destination: selectedChainId, - error_message: quote.message, - amount: params.amount as number, - }), - ); - } + quotes + .filter((quote): quote is QuoteError => Boolean(quote.error)) + .forEach((quote) => + trackEvent('ONRAMP_QUOTE_ERROR', { + provider_onramp: quote.provider.name, + currency_source: params.fiatCurrency?.symbol, + currency_destination: params.asset?.symbol, + payment_method_id: selectedPaymentMethodId as string, + chain_id_destination: selectedChainId, + error_message: quote.message, + amount: params.amount, + }), + ); } }, [ appConfig.POLLING_CYCLES, @@ -437,7 +385,6 @@ const GetQuotes = () => { quotes, selectedChainId, selectedPaymentMethodId, - shouldFinishAnimation, trackEvent, ]); @@ -447,150 +394,6 @@ const GetQuotes = () => { } }, [filteredQuotes]); - const handleOnQuotePress = useCallback((quote: QuoteResponse) => { - 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, - }); - } - }, - [trackEvent], - ); - - const handleOnPressBuy = useCallback( - async (quote: QuoteResponse, index) => { - if (!quote?.buy) { - return; - } - try { - setIsQuoteLoading(true); - - const totalFee = - (quote.networkFee || 0) + - (quote.providerFee || 0) + - (quote.extraFee || 0); - - trackEvent('ONRAMP_PROVIDER_SELECTED', { - provider_onramp: quote.provider.name, - 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), - }); - - const buyAction = await quote.buy(); - if ( - buyAction.browser === ProviderBuyFeatureBrowserEnum.InAppOsBrowser - ) { - await renderInAppBrowser( - buyAction, - quote.provider, - quote.amountIn as number, - quote.fiat?.symbol, - ); - } else if ( - buyAction.browser === ProviderBuyFeatureBrowserEnum.AppBrowser - ) { - const { url, orderId: customOrderId } = await buyAction.createWidget( - callbackBaseUrl, - ); - navigation.navigate( - ...createCheckoutNavDetails({ - provider: quote.provider, - url, - customOrderId, - }), - ); - } else { - throw new Error('Unsupported browser type: ' + buyAction.browser); - } - } finally { - setIsQuoteLoading(false); - } - }, - [ - appConfig.POLLING_CYCLES, - callbackBaseUrl, - filteredQuotes.length, - navigation, - params, - pollingCyclesLeft, - renderInAppBrowser, - selectedChainId, - selectedPaymentMethodId, - trackEvent, - ], - ); - - const handleFetchQuotes = useCallback(() => { - setIsLoading(true); - setFirstFetchCompleted(false); - setIsInPolling(true); - setPollingCyclesLeft(appConfig.POLLING_CYCLES - 1); - setRemainingTime(appConfig.POLLING_INTERVAL); - fetchQuotes(); - trackEvent('ONRAMP_QUOTES_REQUESTED', { - currency_source: params.fiatCurrency?.symbol, - currency_destination: params.asset?.symbol, - payment_method_id: selectedPaymentMethodId as string, - chain_id_destination: selectedChainId, - amount: params.amount as number, - location: 'Quotes Screen', - }); - }, [ - appConfig.POLLING_CYCLES, - appConfig.POLLING_INTERVAL, - fetchQuotes, - params, - selectedChainId, - selectedPaymentMethodId, - trackEvent, - ]); - - const QuotesPolling = () => ( - - {isFetchingQuotes ? ( - <> - - {strings('fiat_on_ramp_aggregator.fetching_new_quotes')} - - ) : ( - - {pollingCyclesLeft > 0 - ? strings('fiat_on_ramp_aggregator.new_quotes_in') - : strings('fiat_on_ramp_aggregator.quotes_expire_in')}{' '} - - {new Date(remainingTime).toISOString().substring(15, 19)} - - - )} - - ); - if (sdkError) { return ( @@ -601,7 +404,6 @@ const GetQuotes = () => { ); } - // Error while FetchingQuotes if (ErrorFetchingQuotes) { return ( { if (pollingCyclesLeft < 0) { return ( - - - - { - - } - - {strings('fiat_on_ramp_aggregator.quotes_timeout')} - - - {strings('fiat_on_ramp_aggregator.request_new_quotes')} - - - - - {strings('fiat_on_ramp_aggregator.get_new_quotes')} - - - - + ); } @@ -675,7 +458,13 @@ const GetQuotes = () => { return ( - {isInPolling && } + {isInPolling && ( + + )} {strings('fiat_on_ramp_aggregator.buy_from_vetted', { @@ -712,26 +501,38 @@ const GetQuotes = () => { {isFetchingQuotes && isInPolling ? ( - <> - - - - + ) : ( filteredQuotes.map((quote, index) => ( - - handleOnQuotePress(quote)} - onPressBuy={() => handleOnPressBuy(quote, index)} - highlighted={quote.provider.id === providerId} - showInfo={() => handleInfoPress(quote)} - /> - + + {index === 0 && ( + + + {strings('fiat_on_ramp_aggregator.best_price')} + + + )} + + {index === 1 && ( + + + {strings( + 'fiat_on_ramp_aggregator.explore_other_options', + )} + + + )} + + handleOnQuotePress(quote)} + onPressBuy={() => handleOnPressBuy(quote, index)} + highlighted={quote.provider.id === providerId} + showInfo={() => handleInfoPress(quote)} + /> + + )) )} @@ -739,6 +540,6 @@ const GetQuotes = () => { ); -}; +} -export default GetQuotes; +export default Quotes; diff --git a/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.types.ts b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.types.ts new file mode 100644 index 00000000000..de63ccce0b3 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Quotes.types.ts @@ -0,0 +1,3 @@ +export type DeepPartial = { + [key in keyof BaseType]?: DeepPartial; +}; diff --git a/app/components/UI/FiatOnRampAggregator/Views/Quotes/Timer.tsx b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Timer.tsx new file mode 100644 index 00000000000..90ff196c423 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/Timer.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { useStyles } from '../../../../hooks/useStyles'; +import { useFiatOnRampSDK } from '../../sdk'; + +import Text from '../../../../Base/Text'; +import styleSheet from './Quotes.styles'; + +import { strings } from '../../../../../../locales/i18n'; + +const Timer = ({ + isFetchingQuotes, + pollingCyclesLeft, + remainingTime, +}: { + isFetchingQuotes: boolean; + pollingCyclesLeft: number; + remainingTime: number; +}) => { + const { appConfig } = useFiatOnRampSDK(); + const { styles } = useStyles(styleSheet, {}); + + return ( + + {isFetchingQuotes ? ( + <> + + {strings('fiat_on_ramp_aggregator.fetching_new_quotes')} + + ) : ( + + {pollingCyclesLeft > 0 + ? strings('fiat_on_ramp_aggregator.new_quotes_in') + : strings('fiat_on_ramp_aggregator.quotes_expire_in')}{' '} + + {new Date(remainingTime).toISOString().substring(15, 19)} + + + )} + + ); +}; + +export default Timer; diff --git a/app/components/UI/FiatOnRampAggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/FiatOnRampAggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap new file mode 100644 index 00000000000..c330e68a0ff --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -0,0 +1,6296 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoadingQuotes component renders correctly 1`] = ` +Array [ + + + + + + + + + + + + + + + + + + + + + + + + , + + + + + + + + + + + + + + + + , + + + + + + + + + + + + + + + + , +] +`; + +exports[`Quotes renders animation on first fetching 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Select a Quote + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + Fetching quotes + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Quotes renders correctly after animation with quotes 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Select a Quote + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + New quotes in + + + 0:07 + + + + + + To buy ETH from one of our integrations, you’ll be securely taken to their portal without leaving the MetaMask app + + + + + + + + + + + + Best price + + + + + + + + + + + + +  + + + + + + + + + + 0.01714 + + ETH + + + + + ≈ + $ + + 46.97 AUD + + + + + + + + + Buy with Banxa (Staging) + + + + + + + + + + + Explore other options + + + + + + + + + + + + +  + + + + + + + + + + 0.0162 + + ETH + + + + + ≈ + $ + + 44.39 AUD + + + + + + + + + Buy with MoonPay (Staging) + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Quotes renders correctly after animation without quotes 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Select a Quote + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + +  + + + + + No providers available + + + + + Try choosing a different payment method or try to increase or reduce the amount you want to buy! + + + + + + Try again + + + + + + + + + + + + + + + +`; + +exports[`Quotes renders correctly when fetching quotes errors 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Select a Quote + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + +  + + + + + Error + + + + + Test Error + + + + + + Try again + + + + + + + + + + + + + + + +`; + +exports[`Quotes renders correctly with sdkError 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Select a Quote + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + + +  + + + + + Oops, something went wrong + + + + + Example SDK Error + + + + + + Return to Home Screen + + + + + + + + + + + + + + + + + + +`; + +exports[`Quotes renders quotes expired screen 1`] = ` + + + + + + + + + + + + + + Back + + + + + + + Select a Quote + + + + + Ethereum Main Network + + + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + +  + + + + + Quotes timeout + + + + + Please request new quotes to get the latest best rate. + + + + + + Get new quotes + + + + + + + + + + + + + + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=false, pollingCyclesLeft=0, remainingTime=5000 1`] = ` + + + Quotes expire in + + + 0:05 + + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=false, pollingCyclesLeft=0, remainingTime=15000 1`] = ` + + + Quotes expire in + + + 0:15 + + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=false, pollingCyclesLeft=0, remainingTime=20000 1`] = ` + + + Quotes expire in + + + 0:20 + + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=false, pollingCyclesLeft=1, remainingTime=5000 1`] = ` + + + New quotes in + + + 0:05 + + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=false, pollingCyclesLeft=1, remainingTime=15000 1`] = ` + + + New quotes in + + + 0:15 + + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=true, pollingCyclesLeft=0, remainingTime=5000 1`] = ` + + + + + Fetching new quotes... + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=true, pollingCyclesLeft=0, remainingTime=15000 1`] = ` + + + + + Fetching new quotes... + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=true, pollingCyclesLeft=1, remainingTime=5000 1`] = ` + + + + + Fetching new quotes... + + +`; + +exports[`Timer component renders correctly with isFetchingQuotes=true, pollingCyclesLeft=1, remainingTime=15000 1`] = ` + + + + + Fetching new quotes... + + +`; diff --git a/app/components/UI/FiatOnRampAggregator/Views/Quotes/index.ts b/app/components/UI/FiatOnRampAggregator/Views/Quotes/index.ts new file mode 100644 index 00000000000..023314060e3 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/Views/Quotes/index.ts @@ -0,0 +1 @@ +export { default } from './Quotes'; diff --git a/app/components/UI/FiatOnRampAggregator/components/ErrorView.tsx b/app/components/UI/FiatOnRampAggregator/components/ErrorView.tsx index c24f0a530b4..3b058813387 100644 --- a/app/components/UI/FiatOnRampAggregator/components/ErrorView.tsx +++ b/app/components/UI/FiatOnRampAggregator/components/ErrorView.tsx @@ -11,7 +11,7 @@ import { ScreenLocation } from '../types'; import useAnalytics from '../hooks/useAnalytics'; import { useFiatOnRampSDK } from '../sdk'; -type IconType = 'error' | 'info'; +type IconType = 'error' | 'info' | 'expired'; const createStyles = (colors: Colors) => StyleSheet.create({ @@ -64,6 +64,11 @@ function ErrorIcon({ icon }: { icon: IconType }) { style = styles.infoIcon; break; } + case 'expired': { + name = 'clock-outline'; + style = styles.infoIcon; + break; + } case 'error': default: { name = 'close-circle-outline'; diff --git a/app/components/UI/FiatOnRampAggregator/components/LoadingAnimation/index.tsx b/app/components/UI/FiatOnRampAggregator/components/LoadingAnimation/index.tsx index 6b440f94822..1fa843a263d 100644 --- a/app/components/UI/FiatOnRampAggregator/components/LoadingAnimation/index.tsx +++ b/app/components/UI/FiatOnRampAggregator/components/LoadingAnimation/index.tsx @@ -1,5 +1,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Animated, View, StyleSheet } from 'react-native'; +import { View, StyleSheet } from 'react-native'; +import Animated, { + cancelAnimation, + runOnJS, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from 'react-native-reanimated'; import Device from '../../../../../util/device'; import BaseTitle from '../../../../Base/Title'; import FoxComponent from '../../../Fox'; @@ -13,7 +21,7 @@ const Fox = FoxComponent as any; const ANIM_MULTIPLIER = 0.67; const START_DURATION = 1000 * ANIM_MULTIPLIER; -const FINISH_DURATION = 750 * ANIM_MULTIPLIER; +const FINISH_DURATION = 500 * ANIM_MULTIPLIER; const DELAY = 1000 * ANIM_MULTIPLIER; const IS_NARROW = Device.getDeviceWidth() <= 320; @@ -95,36 +103,40 @@ function LoadingAnimation({ /* References */ const foxRef = useRef(); - const progressValue = useRef(new Animated.Value(0)).current; - const progressWidth = progressValue.interpolate({ - inputRange: [0, 100], - outputRange: ['0%', '100%'], - }); + const progressWidth = useSharedValue(0); const startAnimation = useCallback(() => { setHasStarted(true); - Animated.sequence([ - Animated.delay(DELAY), - Animated.timing(progressValue, { - toValue: FINALIZING_PERCENTAGE, + progressWidth.value = withDelay( + DELAY, + withTiming(FINALIZING_PERCENTAGE, { duration: START_DURATION, - useNativeDriver: false, }), - ]).start(); - }, [progressValue]); + ); + }, [progressWidth]); const endAnimation = useCallback(() => { setHasStartedFinishing(true); - Animated.timing(progressValue, { - toValue: 100, - duration: FINISH_DURATION, - useNativeDriver: false, - }).start(() => { - if (onAnimationEnd) { - onAnimationEnd(); - } - }); - }, [onAnimationEnd, progressValue]); + cancelAnimation(progressWidth); + progressWidth.value = withTiming( + 100, + { + duration: FINISH_DURATION, + }, + () => { + if (onAnimationEnd) { + runOnJS(onAnimationEnd)(); + } + }, + ); + }, [onAnimationEnd, progressWidth]); + + const progressStyle = useAnimatedStyle( + () => ({ + width: `${progressWidth.value}%`, + }), + [progressWidth], + ); /* Effects */ @@ -135,11 +147,9 @@ function LoadingAnimation({ } }, [endAnimation, finish, hasStartedFinishing]); - useEffect(() => { - if (!hasStarted) { - startAnimation(); - } - }, [hasStarted, startAnimation]); + if (!hasStarted) { + startAnimation(); + } return ( @@ -149,9 +159,7 @@ function LoadingAnimation({ - + diff --git a/app/components/UI/FiatOnRampAggregator/components/Quote/Quote.styles.ts b/app/components/UI/FiatOnRampAggregator/components/Quote/Quote.styles.ts new file mode 100644 index 00000000000..f134a67a2fd --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/components/Quote/Quote.styles.ts @@ -0,0 +1,28 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + return StyleSheet.create({ + fee: { + marginLeft: 8, + }, + buyButton: { + marginTop: 10, + }, + title: { + flexDirection: 'row', + alignItems: 'center', + }, + infoIcon: { + marginLeft: 8, + color: colors.icon.alternative, + }, + data: { + marginTop: 4, + overflow: 'hidden', + }, + }); +}; +export default styleSheet; diff --git a/app/components/UI/FiatOnRampAggregator/components/Quote.tsx b/app/components/UI/FiatOnRampAggregator/components/Quote/Quote.tsx similarity index 54% rename from app/components/UI/FiatOnRampAggregator/components/Quote.tsx rename to app/components/UI/FiatOnRampAggregator/components/Quote/Quote.tsx index 4090ae09377..2ae76422716 100644 --- a/app/components/UI/FiatOnRampAggregator/components/Quote.tsx +++ b/app/components/UI/FiatOnRampAggregator/components/Quote/Quote.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { - StyleSheet, View, TouchableOpacity, LayoutChangeEvent, @@ -16,48 +15,28 @@ import Animated, { WithTimingConfig, } from 'react-native-reanimated'; import { QuoteResponse } from '@consensys/on-ramp-sdk'; -import Box from './Box'; -import Text from '../../../Base/Text'; -import Title from '../../../Base/Title'; -import BaseListItem from '../../../Base/ListItem'; -import StyledButton from '../../StyledButton'; +import { ProviderEnvironmentTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; +import Box from '../Box'; +import Text from '../../../../Base/Text'; +import Title from '../../../../Base/Title'; +import BaseListItem from '../../../../Base/ListItem'; +import StyledButton from '../../../StyledButton'; import { renderFiat, renderFromTokenMinimalUnit, toTokenMinimalUnit, -} from '../../../../util/number'; -import { strings } from '../../../../../locales/i18n'; -import ApplePayButton from '../containers/ApplePayButton'; -import { useTheme } from '../../../../util/theme'; -import RemoteImage from '../../../Base/RemoteImage'; +} from '../../../../../util/number'; +import { strings } from '../../../../../../locales/i18n'; +import ApplePayButton from '../../containers/ApplePayButton'; +import RemoteImage from '../../../../Base/RemoteImage'; -import { Colors } from '../../../../util/theme/models'; +import Row from '../Row'; +import styleSheet from './Quote.styles'; +import { useStyles } from '../../../../../component-library/hooks'; // TODO: Convert into typescript and correctly type optionals const ListItem = BaseListItem as any; -const createStyles = (colors: Colors) => - StyleSheet.create({ - fee: { - marginLeft: 8, - }, - buyButton: { - marginTop: 10, - }, - title: { - flexDirection: 'row', - alignItems: 'center', - }, - infoIcon: { - marginLeft: 8, - color: colors.icon.alternative, - }, - data: { - marginTop: 4, - overflow: 'hidden', - }, - }); - interface Props { quote: QuoteResponse; onPress?: () => any; @@ -80,8 +59,10 @@ const Quote: React.FC = ({ isLoading, highlighted, }: Props) => { - const { colors, themeAppearance } = useTheme(); - const styles = createStyles(colors); + const { + styles, + theme: { colors, themeAppearance }, + } = useStyles(styleSheet, {}); const { networkFee = 0, providerFee = 0, @@ -90,12 +71,13 @@ const Quote: React.FC = ({ fiat, provider, crypto, + amountOutInFiat, } = quote; const totalFees = networkFee + providerFee; const price = amountIn - totalFees; - const fiatCode = fiat?.symbol || ''; - const fiatSymbol = fiat?.denomSymbol || ''; + const fiatCode = fiat?.symbol ?? ''; + const fiatSymbol = fiat?.denomSymbol ?? ''; const expandedHeight = useSharedValue(0); const handleOnLayout = (event: LayoutChangeEvent) => { @@ -135,7 +117,8 @@ const Quote: React.FC = ({ {quote.provider?.logos?.[themeAppearance] ? ( @@ -156,92 +139,45 @@ const Quote: React.FC = ({ - - - {renderFromTokenMinimalUnit( - toTokenMinimalUnit(amountOut, crypto?.decimals || 0).toString(), - crypto?.decimals || 0, - )}{' '} - {crypto?.symbol} - - - - - - - {strings('fiat_on_ramp_aggregator.price')} {fiatCode} - - - - - ≈ {fiatSymbol} {renderFiat(price, fiatCode, fiat?.decimals)} - - - - + - - {strings('fiat_on_ramp_aggregator.total_fees')} + + {renderFromTokenMinimalUnit( + toTokenMinimalUnit( + amountOut, + crypto?.decimals ?? 0, + ).toString(), + crypto?.decimals ?? 0, + )}{' '} + {crypto?.symbol} - - {fiatSymbol} {renderFiat(totalFees, fiatCode, fiat?.decimals)} - - - - - - - - {strings('fiat_on_ramp_aggregator.processing_fee')} - - - - - {fiatSymbol} {renderFiat(providerFee, fiatCode, fiat?.decimals)} - - - - - - - - {strings('fiat_on_ramp_aggregator.network_fee')} - - - - - {fiatSymbol} - {renderFiat(networkFee, fiatCode, fiat?.decimals)} - - - - - - - - {strings('fiat_on_ramp_aggregator.total')} - - - - - {fiatSymbol} {renderFiat(amountIn, fiatCode, fiat?.decimals)} + + ≈ {fiatSymbol}{' '} + {renderFiat(amountOutInFiat ?? price, fiatCode, fiat?.decimals)} + + - {quote.paymentMethod?.isApplePay ? ( + {quote.isNativeApplePay ? ( ) : ( ; +}) => ( + + + + + + + + + + + + {!collapsed && ( + <> + + + + + + + + + + )} + +); + +export default SkeletonQuote; diff --git a/app/components/UI/FiatOnRampAggregator/components/SkeletonQuote/index.tsx b/app/components/UI/FiatOnRampAggregator/components/SkeletonQuote/index.tsx new file mode 100644 index 00000000000..f3d8768f1d3 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/components/SkeletonQuote/index.tsx @@ -0,0 +1 @@ +export { default } from './SkeletonQuote'; diff --git a/app/components/UI/FiatOnRampAggregator/hooks/useQuotes.ts b/app/components/UI/FiatOnRampAggregator/hooks/useQuotes.ts new file mode 100644 index 00000000000..1b30d97bcb4 --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/hooks/useQuotes.ts @@ -0,0 +1,30 @@ +import { useFiatOnRampSDK } from '../sdk'; +import useSDKMethod from './useSDKMethod'; + +function useQuotes(amount: number) { + const { + selectedPaymentMethodId, + selectedRegion, + selectedAsset, + selectedAddress, + selectedFiatCurrencyId, + } = useFiatOnRampSDK(); + const [{ data, isFetching, error }, query] = useSDKMethod( + 'getQuotes', + selectedRegion?.id, + selectedPaymentMethodId, + selectedAsset?.id, + selectedFiatCurrencyId, + amount, + selectedAddress, + ); + + return { + data, + isFetching, + error, + query, + }; +} + +export default useQuotes; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 67e3fd153f1..3bdc4667c7c 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -10,7 +10,7 @@ const Routes = { PAYMENT_METHOD: 'PaymentMethod', PAYMENT_METHOD_HAS_STARTED: 'PaymentMethodHasStarted', AMOUNT_TO_BUY: 'AmountToBuy', - GET_QUOTES: 'GetQuotes', + QUOTES: 'Quotes', CHECKOUT: 'Checkout', REGION: 'Region', REGION_HAS_STARTED: 'RegionHasStarted', diff --git a/locales/languages/en.json b/locales/languages/en.json index f261d7b42cc..ffff37a7162 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1872,11 +1872,8 @@ "no_tokens_match": "No tokens match “{{searchString}}”", "no_currency_match": "No currencies match “{{searchString}}”", "buy_from_vetted": "To buy {{ticker}} from one of our integrations, you’ll be securely taken to their portal without leaving the MetaMask app", - "price": "Price", - "total_fees": "Total Fees", - "processing_fee": "Processing Fee", - "network_fee": "Network Fee", - "total": "Total", + "best_price": "Best price", + "explore_other_options": "Explore other options", "pay_with": "Pay with", "buy_with": "Buy with {{provider}}", "minimum": "Minimum deposit is",