import { RouteProp } from '@react-navigation/native' import { NativeStackScreenProps } from '@react-navigation/native-stack' import _ from 'lodash' import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useAsync } from 'react-async-hook' import { Trans, useTranslation } from 'react-i18next' import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { showError } from 'src/alert/actions' import AppAnalytics from 'src/analytics/AppAnalytics' import { FiatExchangeEvents } from 'src/analytics/Events' import { ErrorMessages } from 'src/app/ErrorMessages' import BackButton from 'src/components/BackButton' import Dialog from 'src/components/Dialog' import TextButton from 'src/components/TextButton' import Touchable from 'src/components/Touchable' import { FETCH_FIATCONNECT_QUOTES } from 'src/config' import { ExternalExchangeProvider } from 'src/fiatExchanges/ExternalExchanges' import { PaymentMethodSection, PaymentMethodSectionMethods, } from 'src/fiatExchanges/PaymentMethodSection' import { CryptoAmount, FiatAmount } from 'src/fiatExchanges/amount' import { normalizeQuotes } from 'src/fiatExchanges/quotes/normalizeQuotes' import { CICOFlow, FiatExchangeFlow, PaymentMethod, ProviderSelectionAnalyticsData, SelectProviderExchangesLink, SelectProviderExchangesText, } from 'src/fiatExchanges/types' import { LegacyMobileMoneyProvider, fetchCicoQuotes, fetchExchanges, fetchLegacyMobileMoneyProviders, filterLegacyMobileMoneyProviders, getProviderSelectionAnalyticsData, } from 'src/fiatExchanges/utils' import { fiatConnectQuotesErrorSelector, fiatConnectQuotesLoadingSelector, fiatConnectQuotesSelector, selectFiatConnectQuoteLoadingSelector, } from 'src/fiatconnect/selectors' import { fetchFiatConnectQuotes } from 'src/fiatconnect/slice' import { getDefaultLocalCurrencyCode, getLocalCurrencyCode, usdToLocalCurrencyRateSelector, } from 'src/localCurrency/selectors' import { emptyHeader } from 'src/navigator/Headers' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { userLocationDataSelector } from 'src/networkInfo/selectors' import { useDispatch, useSelector } from 'src/redux/hooks' import { getDynamicConfigParams, getFeatureGate } from 'src/statsig' import { DynamicConfigs } from 'src/statsig/constants' import { StatsigDynamicConfigs, StatsigFeatureGates } from 'src/statsig/types' import colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import variables from 'src/styles/variables' import { useTokenInfo } from 'src/tokens/hooks' import { NetworkId } from 'src/transactions/types' import Logger from 'src/utils/Logger' import { navigateToURI } from 'src/utils/linking' import networkConfig from 'src/web3/networkConfig' import { currentAccountSelector } from 'src/web3/selectors' const TAG = 'SelectProviderScreen' type Props = NativeStackScreenProps const paymentMethodSections: PaymentMethodSectionMethods[] = [ PaymentMethod.Card, PaymentMethod.Bank, PaymentMethod.FiatConnectMobileMoney, PaymentMethod.Airtime, ] export default function SelectProviderScreen({ route, navigation }: Props) { const dispatch = useDispatch() const { flow, tokenId, amount: { crypto: cryptoAmount, fiat: fiatAmount }, } = route.params const userLocation = useSelector(userLocationDataSelector) const account = useSelector(currentAccountSelector) const localCurrency = useSelector(getLocalCurrencyCode) const defaultCurrency = useSelector(getDefaultLocalCurrencyCode) const fiatConnectQuotes = useSelector(fiatConnectQuotesSelector) const fiatConnectQuotesLoading = useSelector(fiatConnectQuotesLoadingSelector) const fiatConnectQuotesError = useSelector(fiatConnectQuotesErrorSelector) const selectFiatConnectQuoteLoading = useSelector(selectFiatConnectQuoteLoadingSelector) const usdToLocalRate = useSelector(usdToLocalCurrencyRateSelector) const tokenInfo = useTokenInfo(tokenId) const { links } = getDynamicConfigParams(DynamicConfigs[StatsigDynamicConfigs.APP_CONFIG]) const showUKCompliantVariant = getFeatureGate(StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT) if (!tokenInfo) { throw new Error(`Token info not found for token ID ${tokenId}`) } const { t } = useTranslation() const insets = useSafeAreaInsets() useEffect(() => { if (FETCH_FIATCONNECT_QUOTES) { dispatch( fetchFiatConnectQuotes({ flow, digitalAsset: tokenInfo.symbol, cryptoAmount, fiatAmount, }) ) } }, [flow, tokenInfo.symbol, cryptoAmount]) useEffect(() => { if (fiatConnectQuotesError) { dispatch(showError(ErrorMessages.PROVIDER_FETCH_FAILED)) } }, [fiatConnectQuotesError]) useLayoutEffect(() => { navigation.setOptions({ headerTitle: route.params.flow === CICOFlow.CashIn ? t(`fiatExchangeFlow.cashIn.selectProviderHeader`, { context: showUKCompliantVariant ? 'UK' : undefined, }) : t(`fiatExchangeFlow.cashOut.selectProviderHeader`), }) }, [route.params.flow]) const asyncExchanges = useAsync(async () => { try { const availableExchanges = await fetchExchanges( userLocation.countryCodeAlpha2, tokenInfo.tokenId ) return availableExchanges } catch (error) { Logger.error(TAG, 'error fetching exchanges, displaying an empty array') return [] } }, []) const asyncProviders = useAsync(async () => { if (!account) { Logger.error(TAG, 'No account set') return } try { const [cicoQuotesResponse, rawLegacyMobileMoneyProviders] = await Promise.all([ fetchCicoQuotes({ tokenId, fiatCurrency: localCurrency, address: account, userLocation, ...(flow === CICOFlow.CashIn ? { txType: 'cashIn', fiatAmount: fiatAmount.toString() } : { txType: 'cashOut', cryptoAmount: cryptoAmount.toString() }), }), fetchLegacyMobileMoneyProviders(), ]) const legacyMobileMoneyProviders = filterLegacyMobileMoneyProviders( rawLegacyMobileMoneyProviders, flow, userLocation.countryCodeAlpha2, tokenInfo.tokenId ) return { legacyMobileMoneyProviders, cicoQuotes: cicoQuotesResponse.quotes } } catch (error) { dispatch(showError(ErrorMessages.PROVIDER_FETCH_FAILED)) } }, []) const quotesLoading = asyncProviders.loading || fiatConnectQuotesLoading || asyncExchanges.loading || selectFiatConnectQuoteLoading const normalizedQuotes = normalizeQuotes({ flow, fiatConnectQuotes, cicoQuotes: asyncProviders.result?.cicoQuotes, tokenId, }) const exchanges = asyncExchanges.result ?? [] const legacyMobileMoneyProviders = asyncProviders.result?.legacyMobileMoneyProviders const anyProviders = normalizedQuotes.length || exchanges.length || legacyMobileMoneyProviders?.length const analyticsData = getProviderSelectionAnalyticsData({ normalizedQuotes, legacyMobileMoneyProviders, usdToLocalRate, tokenInfo, centralizedExchanges: exchanges, transferCryptoAmount: cryptoAmount, cryptoType: tokenInfo.symbol, }) useEffect(() => { if (!quotesLoading) { AppAnalytics.track(FiatExchangeEvents.cico_providers_fetch_quotes_result, { fiatType: localCurrency, defaultFiatType: defaultCurrency, ..._.omit(analyticsData, 'transferCryptoAmount'), ...(flow === CICOFlow.CashIn ? { flow, fiatAmount, cryptoAmount: undefined } : { flow, cryptoAmount, fiatAmount: undefined, }), }) } }, [quotesLoading, localCurrency, defaultCurrency, analyticsData, flow, fiatAmount, cryptoAmount]) if (quotesLoading) { return ( ) } const availablePaymentMethods = normalizedQuotes.map((quote) => quote.getPaymentMethod()) const somePaymentMethodsUnavailable = !paymentMethodSections.every((method) => availablePaymentMethods.includes(method) ) const supportOnPress = () => navigate(Screens.SupportContact) const handlePressDisclaimer = () => { navigate(Screens.WebViewScreen, { uri: links.funding }) } const switchCurrencyOnPress = () => { navigate(Screens.FiatExchangeCurrencyBottomSheet, { flow: flow === CICOFlow.CashIn ? FiatExchangeFlow.CashIn : FiatExchangeFlow.CashOut, }) } if (!anyProviders) { return ( {t('noPaymentMethods', { digitalAsset: tokenInfo.symbol, })} {t('switchCurrency')} {t('contactSupport')} ) } return ( {paymentMethodSections.map((paymentMethod) => ( ))} {(tokenInfo.networkId === NetworkId['celo-mainnet'] || tokenInfo.networkId === NetworkId['celo-alfajores']) && ( )} {showUKCompliantVariant && ( {t('selectProviderScreen.disclaimerUK')} )} {somePaymentMethodsUnavailable ? ( ) : ( )} ) } function AmountSpentInfo({ flow, tokenId, amount }: Props['route']['params']) { const localCurrency = useSelector(getLocalCurrencyCode) return ( {flow === CICOFlow.CashIn ? ( ) : ( )} ) } function LimitedPaymentMethods({ flow }: { flow: CICOFlow }) { const { t } = useTranslation() const [isDialogVisible, setIsDialogVisible] = useState(false) const dismissDialog = () => { setIsDialogVisible(false) } const openDialog = () => { AppAnalytics.track(FiatExchangeEvents.cico_providers_unavailable_selected, { flow, }) setIsDialogVisible(true) } useEffect(() => { AppAnalytics.track(FiatExchangeEvents.cico_providers_unavailable_impression, { flow, }) }, []) return ( <> {t('selectProviderScreen.missingPaymentsExplained')} ) } function ExchangesSection({ exchanges = [], flow, selectedTokenId, analyticsData, }: { exchanges: ExternalExchangeProvider[] flow: CICOFlow selectedTokenId: string analyticsData: ProviderSelectionAnalyticsData }) { const { t } = useTranslation() const { addFundsExchangesText: exchangesText, addFundsExchangesLink: exchangesLink } = useMemo(() => { if (flow === CICOFlow.CashIn) { return { addFundsExchangesText: SelectProviderExchangesText.DepositFrom, addFundsExchangesLink: SelectProviderExchangesLink.ExchangeQRScreen, } } return { addFundsExchangesText: SelectProviderExchangesText.CryptoExchange, addFundsExchangesLink: SelectProviderExchangesLink.ExternalExchangesScreen, } }, [flow]) if (!exchanges.length) { return null } const goToExchangesScreen = () => { AppAnalytics.track(FiatExchangeEvents.cico_providers_exchanges_selected, { flow, ...analyticsData, }) if (exchangesLink === SelectProviderExchangesLink.ExchangeQRScreen) { navigate(Screens.ExchangeQR, { flow, exchanges }) } else { navigate(Screens.ExternalExchanges, { tokenId: selectedTokenId, exchanges, }) } } let header: string let text: string let rightText: string | null = null if (exchangesText === SelectProviderExchangesText.DepositFrom) { header = t('selectProviderScreen.depositFrom') text = t('selectProviderScreen.cryptoExchangeOrWallet') } else { header = t('selectProviderScreen.cryptoExchange') text = t('selectProviderScreen.feesVary') rightText = t('selectProviderScreen.viewExchanges') } return ( {header} {text} {!!rightText && ( {rightText} )} ) } function LegacyMobileMoneySection({ providers, tokenId, flow, analyticsData, }: { providers: LegacyMobileMoneyProvider[] tokenId: string flow: CICOFlow analyticsData: ProviderSelectionAnalyticsData }) { const { t } = useTranslation() /** * This component assumes that there is only one legacy mobile money provider at a time. When we add FiatConnect mobile money providers this * assumption will no longer be true and the UI will have to be updated to be more dynamic. Consider making PaymentMethodSection more * flexible to be able to handle mobile money as well when we start adding FiatConnect support. */ const provider = providers[0] useEffect(() => { if (provider) { AppAnalytics.track(FiatExchangeEvents.cico_providers_section_impression, { flow, paymentMethod: PaymentMethod.MobileMoney, quoteCount: 1, providers: [provider.name], }) } }, []) const goToProviderSite = () => { AppAnalytics.track(FiatExchangeEvents.cico_providers_quote_selected, { flow, paymentMethod: PaymentMethod.MobileMoney, provider: provider.name, feeCryptoAmount: undefined, kycRequired: false, isLowestFee: undefined, ...analyticsData, }) navigateToURI(provider[tokenId === networkConfig.cusdTokenId ? 'cusd' : 'celo'].url) } if (!provider) { return null } return ( {t('selectProviderScreen.feesVary')} {provider.name} ) } const styles = StyleSheet.create({ activityIndicatorContainer: { paddingVertical: variables.contentPadding, flex: 1, alignContent: 'center', justifyContent: 'center', }, container: { borderBottomWidth: 1, borderBottomColor: colors.borderPrimary, }, expandableContainer: { paddingHorizontal: Spacing.Regular16, justifyContent: 'space-between', flexDirection: 'row', alignItems: 'center', }, noPaymentMethods: { ...typeScale.bodyMedium, padding: variables.contentPadding, textAlign: 'center', }, switchCurrency: { ...typeScale.labelLarge, color: colors.accent, padding: Spacing.Smallest8, }, noPaymentMethodsContainer: { alignItems: 'center', padding: Spacing.Thick24, }, left: { flex: 1, }, right: { flexDirection: 'column', justifyContent: 'center', }, categoryHeader: { ...typeScale.bodySmall, }, categoryText: { ...typeScale.labelSemiBoldSmall, marginTop: 4, }, linkToOtherScreen: { ...typeScale.labelSmall, color: colors.contentSecondary, }, disclaimerContainer: { padding: Spacing.Regular16, }, disclaimerUKContainer: { paddingTop: Spacing.Thick24, paddingHorizontal: Spacing.Regular16, }, disclaimerText: { ...typeScale.bodySmall, color: colors.contentSecondary, }, underline: { textDecorationLine: 'underline', }, dialog: { ...typeScale.bodyMedium, textAlign: 'center', }, contactSupport: { ...typeScale.labelLarge, color: colors.textLink, padding: Spacing.Smallest8, }, amountSpentInfo: { marginHorizontal: 16, marginBottom: 8, padding: 16, backgroundColor: colors.backgroundSecondary, borderRadius: 16, }, amountSpentInfoText: { textAlign: 'center', ...typeScale.labelSemiBoldXSmall, }, }) SelectProviderScreen.navigationOptions = ({ route, }: { route: RouteProp }) => ({ ...emptyHeader, headerLeft: () => ( ), })