import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { View, StyleSheet, ActivityIndicator, StyleProp, ViewStyle, } from 'react-native'; import { useNavigation, useRoute } 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 { useFiatOnRampSDK, useSDKMethod } from '../sdk'; import ScreenLayout from '../components/ScreenLayout'; import ScreenView from '../../FiatOrders/components/ScreenView'; 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 BaseText 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'; import useAnalytics from '../hooks/useAnalytics'; // TODO: Convert into typescript and correctly type const Text = BaseText as any; const ListItem = BaseListItem as any; 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, marginVertical: 12, 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, }, }); 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 = () => { const { selectedPaymentMethodId, selectedRegion, selectedAsset, selectedAddress, selectedFiatCurrencyId, selectedChainId, appConfig, callbackBaseUrl, sdkError, } = useFiatOnRampSDK(); const { colors } = useTheme(); const styles = createStyles(colors); const { params } = useRoute(); const navigation = useNavigation(); const trackEvent = useAnalytics(); const [isLoading, setIsLoading] = useState(true); const [shouldFinishAnimation, setShouldFinishAnimation] = useState(false); const [firstFetchCompleted, setFirstFetchCompleted] = useState(false); const [isInPolling, setIsInPolling] = useState(false); 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 scrollOffsetY = useSharedValue(0); const scrollHandler = useAnimatedScrollHandler((event) => { scrollOffsetY.value = event.contentOffset.y; }); const animatedStyles = useAnimatedStyle(() => { const value = interpolate( scrollOffsetY.value, [0, 50], [0, 1], Extrapolate.EXTEND, ); return { opacity: value }; }); const [ { data: quotes, isFetching: isFetchingQuotes, error: ErrorFetchingQuotes }, fetchQuotes, ] = useSDKMethod( 'getQuotes', selectedRegion?.id, selectedPaymentMethodId, selectedAsset?.id, selectedFiatCurrencyId, // @ts-expect-error useRoute params params?.amount, selectedAddress, callbackBaseUrl, ); const filteredQuotes: QuoteResponse[] = useMemo( () => (quotes || []).filter(({ error }) => !error).sort(sortByAmountOut), [quotes], ); const handleCancelPress = useCallback(() => { trackEvent('ONRAMP_CANCELED', { location: 'Quotes Screen', chain_id_destination: selectedChainId, results_count: filteredQuotes.length, }); }, [filteredQuotes.length, selectedChainId, trackEvent]); // we only activate this interval polling once the first fetch of quotes is successfull useInterval( () => { setRemainingTime((prevRemainingTime) => { const newRemainingTime = Number(prevRemainingTime - 1000); if (newRemainingTime <= 0) { setPollingCyclesLeft((cycles) => cycles - 1); // we never fetch data if we run out of remaining polling cycles setShowProviderInfo(false); if (pollingCyclesLeft > 0) { setProviderId(null); fetchQuotes(); } } return newRemainingTime > 0 ? newRemainingTime : appConfig.POLLING_INTERVAL; }); }, isInPolling ? 1000 : null, ); // Listen to the event of first fetch completed useEffect(() => { if ( !firstFetchCompleted && !isInPolling && !ErrorFetchingQuotes && !isFetchingQuotes && filteredQuotes && filteredQuotes.length ) { setFirstFetchCompleted(true); setIsInPolling(true); } }, [ ErrorFetchingQuotes, filteredQuotes, firstFetchCompleted, isFetchingQuotes, 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); setShowProviderInfo(false); setProviderId(null); } }, [ErrorFetchingQuotes, pollingCyclesLeft]); useEffect(() => { navigation.setOptions( getFiatOnRampAggNavbar( navigation, { title: strings('fiat_on_ramp_aggregator.select_a_quote') }, colors, handleCancelPress, ), ); }, [navigation, colors, handleCancelPress]); useEffect(() => { if (isFetchingQuotes) return; setShouldFinishAnimation(true); }, [isFetchingQuotes]); useEffect(() => { if ( shouldFinishAnimation && quotes && !isFetchingQuotes && pollingCyclesLeft >= 0 ) { const quotesWithoutError: QuoteResponse[] = quotes .filter(({ error }) => !error) .sort(sortByAmountOut); if (quotesWithoutError.length > 0) { const totals = quotesWithoutError.reduce( (acc, curr) => { const totalFee = acc.totalFee + ((curr?.networkFee || 0) + (curr?.providerFee || 0) + (curr?.extraFee || 0)); return { amountOut: acc.amountOut + (curr?.amountOut || 0), totalFee, totalGasFee: acc.totalGasFee + (curr?.networkFee || 0), totalProccessingFee: acc.totalProccessingFee + (curr?.providerFee || 0), feeAmountRatio: acc.feeAmountRatio + totalFee / (curr?.amountOut || 0), }; }, { amountOut: 0, totalFee: 0, totalGasFee: 0, totalProccessingFee: 0, feeAmountRatio: 0, }, ); trackEvent('ONRAMP_QUOTES_RECEIVED', { currency_source: (params as any)?.fiatCurrency?.symbol as string, currency_destination: (params as any)?.asset?.symbol as string, chain_id_destination: selectedChainId, amount: (params as any)?.amount as number, 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.totalProccessingFee / 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, }); } if (quotes.length > quotesWithoutError.length) { quotes .filter(({ error }) => Boolean(error)) .forEach((quote) => trackEvent('ONRAMP_QUOTE_ERROR', { provider_onramp: quote.provider.name, currency_source: (params as any)?.fiatCurrency?.symbol as string, currency_destination: (params as any)?.asset?.symbol as string, payment_method_id: selectedPaymentMethodId as string, chain_id_destination: selectedChainId, error_message: quote.message, amount: (params as any)?.amount as number, }), ); } } }, [ appConfig.POLLING_CYCLES, filteredQuotes, isFetchingQuotes, params, pollingCyclesLeft, quotes, selectedChainId, selectedPaymentMethodId, shouldFinishAnimation, trackEvent, ]); useEffect(() => { if (filteredQuotes && filteredQuotes.length > 0) { setProviderId(filteredQuotes[0].provider?.id); } }, [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( (quote, index) => { quote?.provider?.id && navigation.navigate('Checkout', { ...quote }); 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 as any)?.fiatCurrency?.symbol as string, currency_destination: (params as any)?.asset?.symbol as string, 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), }); }, [ appConfig.POLLING_CYCLES, filteredQuotes.length, navigation, params, pollingCyclesLeft, 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 as any)?.fiatCurrency?.symbol as string, currency_destination: (params as any)?.asset?.symbol as string, payment_method_id: selectedPaymentMethodId as string, chain_id_destination: selectedChainId, amount: (params as any)?.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 ( ); } // 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')} ); } if (isLoading) { return ( setIsLoading(false)} /> ); } // No providers available if (!isFetchingQuotes && filteredQuotes.length === 0) { return ( navigation.goBack()} /> ); } return ( {isInPolling && } {strings('fiat_on_ramp_aggregator.buy_from_vetted', { // @ts-expect-error params useRute type ticker: params?.asset?.symbol || '', })} setShowProviderInfo(false)} providerName={selectedProviderInfo?.name} logos={selectedProviderInfo?.logos} subtitle={selectedProviderInfo?.hqAddress} body={selectedProviderInfo?.description} providerWebsite={ selectedProviderInfo?.links?.find( (link) => link.name === PROVIDER_LINKS.HOMEPAGE, )?.url } providerPrivacyPolicy={ selectedProviderInfo?.links?.find( (link) => link.name === PROVIDER_LINKS.PRIVACY_POLICY, )?.url } providerSupport={ selectedProviderInfo?.links?.find( (link) => link.name === PROVIDER_LINKS.SUPPORT, )?.url } /> {isFetchingQuotes && isInPolling ? ( <> ) : ( filteredQuotes.map((quote, index) => ( handleOnQuotePress(quote)} onPressBuy={() => handleOnPressBuy(quote, index)} highlighted={quote.provider.id === providerId} showInfo={() => handleInfoPress(quote)} /> )) )} ); }; export default GetQuotes;