diff --git a/locales/base/translation.json b/locales/base/translation.json index cc720b1126e..4f6f60a5723 100644 --- a/locales/base/translation.json +++ b/locales/base/translation.json @@ -2332,6 +2332,20 @@ } }, "earnFlow": { + "addCryptoBottomSheet": { + "title": "Add {{tokenSymbol}} on {{tokenNetwork}}", + "description": "Once you add tokens you'll have to come back to finish depositing into a pool.", + "actions": { + "add": "Buy", + "transfer": "Transfer", + "swap": "Swap" + }, + "actionDescriptions": { + "add": "Buy {{tokenSymbol}} on {{tokenNetwork}} using one of our trusted providers", + "transfer": "Use any {{tokenNetwork}} compatible wallet or exchange to deposit {{tokenSymbol}}", + "swap": "Swap into {{tokenSymbol}} from another {{tokenNetwork}} token" + } + }, "cta": { "title": "Earn on your stablecoins", "subtitle": "Deposit today and earn returns", diff --git a/src/analytics/Events.tsx b/src/analytics/Events.tsx index 5ece8edbf9b..c07dc7ce34a 100644 --- a/src/analytics/Events.tsx +++ b/src/analytics/Events.tsx @@ -677,4 +677,5 @@ export enum PointsEvents { export enum EarnEvents { earn_cta_press = 'earn_cta_press', + earn_add_crypto_action_press = 'earn_add_crypto_action_press', } diff --git a/src/analytics/Properties.tsx b/src/analytics/Properties.tsx index f8133d480e4..b370a25f3f6 100644 --- a/src/analytics/Properties.tsx +++ b/src/analytics/Properties.tsx @@ -72,7 +72,7 @@ import { PointsActivityId } from 'src/points/types' import { RecipientType } from 'src/recipients/recipient' import { AmountEnteredIn, QrCode } from 'src/send/types' import { Field } from 'src/swap/types' -import { TokenDetailsActionName } from 'src/tokens/types' +import { TokenActionName } from 'src/tokens/types' import { NetworkId, TokenTransactionTypeV2, TransactionStatus } from 'src/transactions/types' type Web3LibraryProps = { web3Library: 'contract-kit' | 'viem' } @@ -1393,11 +1393,11 @@ interface AssetsEventsProperties { } & TokenProperties) [AssetsEvents.tap_claim_rewards]: undefined [AssetsEvents.tap_token_details_action]: { - action: TokenDetailsActionName + action: TokenActionName } & TokenProperties [AssetsEvents.tap_token_details_learn_more]: TokenProperties [AssetsEvents.tap_token_details_bottom_sheet_action]: { - action: TokenDetailsActionName + action: TokenActionName } & TokenProperties [AssetsEvents.import_token_screen_open]: undefined [AssetsEvents.import_token_submit]: { @@ -1575,6 +1575,9 @@ interface PointsEventsProperties { interface EarnEventsProperties { [EarnEvents.earn_cta_press]: undefined + [EarnEvents.earn_add_crypto_action_press]: { + action: TokenActionName + } & TokenProperties } export type AnalyticsPropertiesList = AppEventsProperties & diff --git a/src/analytics/docs.ts b/src/analytics/docs.ts index 8d840e1b9b3..5fdd6a14437 100644 --- a/src/analytics/docs.ts +++ b/src/analytics/docs.ts @@ -592,6 +592,7 @@ export const eventDocs: Record = { // Events related to earn program [EarnEvents.earn_cta_press]: `When a user taps on the earn your stablecoins CTA on the discover tab`, + [EarnEvents.earn_add_crypto_action_press]: `When a user in the Earn flow enters an amount higher than their balance and chooses an option to add crypto`, // Legacy event docs // The below events had docs, but are no longer produced by the latest app version. diff --git a/src/earn/EarnAddCryptoBottomSheet.test.tsx b/src/earn/EarnAddCryptoBottomSheet.test.tsx new file mode 100644 index 00000000000..99d5a1d42ad --- /dev/null +++ b/src/earn/EarnAddCryptoBottomSheet.test.tsx @@ -0,0 +1,190 @@ +import { fireEvent, render } from '@testing-library/react-native' +import BigNumber from 'bignumber.js' +import React from 'react' +import { Provider } from 'react-redux' +import { EarnEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import EarnAddCryptoBottomSheet from 'src/earn/EarnAddCryptoBottomSheet' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import { getDynamicConfigParams } from 'src/statsig' +import { StoredTokenBalance, TokenBalance } from 'src/tokens/slice' +import { TokenActionName } from 'src/tokens/types' +import { NetworkId } from 'src/transactions/types' +import { createMockStore } from 'test/utils' + +jest.mock('src/statsig', () => ({ + getDynamicConfigParams: jest.fn(), + getFeatureGate: jest.fn().mockReturnValue(false), +})) + +const mockStoredArbitrumUsdcTokenBalance: StoredTokenBalance = { + tokenId: 'arbitrum-sepolia:0x123', + priceUsd: '1.16', + address: '0x123', + isNative: false, + symbol: 'USDC', + imageUrl: + 'https://raw.githubusercontent.com/ubeswap/default-token-list/master/assets/asset_CELO.png', + name: 'USDC', + decimals: 6, + balance: '5', + isFeeCurrency: true, + canTransferWithComment: false, + priceFetchedAt: Date.now(), + networkId: NetworkId['arbitrum-sepolia'], + isSwappable: true, + isCashInEligible: true, + isCashOutEligible: true, +} + +const mockArbitrumUsdcBalance: TokenBalance = { + ...mockStoredArbitrumUsdcTokenBalance, + balance: new BigNumber(mockStoredArbitrumUsdcTokenBalance.balance!), + lastKnownPriceUsd: new BigNumber(mockStoredArbitrumUsdcTokenBalance.priceUsd!), + priceUsd: new BigNumber(mockStoredArbitrumUsdcTokenBalance.priceUsd!), +} + +const store = createMockStore({ + tokens: { + tokenBalances: { + ['arbitrum-sepolia:0x123']: { + ...mockStoredArbitrumUsdcTokenBalance, + balance: `${mockStoredArbitrumUsdcTokenBalance.balance!}`, + }, + ['arbitrum-sepolia:0x456']: { + ...mockStoredArbitrumUsdcTokenBalance, + address: '0x456', + tokenId: 'arbitrum-sepolia:0x456', + balance: `${mockStoredArbitrumUsdcTokenBalance.balance!}`, + }, + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, +}) + +describe('EarnAddCryptoBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(getDynamicConfigParams).mockReturnValue({ + showCico: ['arbitrum-sepolia'], + showSwap: ['arbitrum-sepolia'], + }) + }) + it('Renders all actions', () => { + const { getByText } = render( + + + + ) + + expect(getByText('earnFlow.addCryptoBottomSheet.actions.transfer')).toBeTruthy() + expect(getByText('earnFlow.addCryptoBottomSheet.actions.swap')).toBeTruthy() + expect(getByText('earnFlow.addCryptoBottomSheet.actions.add')).toBeTruthy() + }) + + it('Does not render swap action when no tokens available to swap', () => { + const mockStore = createMockStore({ + tokens: { + tokenBalances: { + ['arbitrum-sepolia:0x123']: { + ...mockStoredArbitrumUsdcTokenBalance, + balance: `${mockStoredArbitrumUsdcTokenBalance.balance!}`, + }, + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + const { getByText, queryByText } = render( + + + + ) + + expect(getByText('earnFlow.addCryptoBottomSheet.actions.transfer')).toBeTruthy() + expect(queryByText('earnFlow.addCryptoBottomSheet.actions.swap')).toBeFalsy() + expect(getByText('earnFlow.addCryptoBottomSheet.actions.add')).toBeTruthy() + }) + + it('Does not render swap or add when network is not in dynamic config', () => { + jest.mocked(getDynamicConfigParams).mockReturnValue({ + showCico: ['ethereum-sepolia'], + showSwap: ['ethereum-sepolia'], + }) + const { getByText, queryByText } = render( + + + + ) + + expect(getByText('earnFlow.addCryptoBottomSheet.actions.transfer')).toBeTruthy() + expect(queryByText('earnFlow.addCryptoBottomSheet.actions.swap')).toBeFalsy() + expect(queryByText('earnFlow.addCryptoBottomSheet.actions.add')).toBeFalsy() + }) + + it.each([ + { + actionName: TokenActionName.Add, + actionTitle: 'earnFlow.addCryptoBottomSheet.actions.add', + navigateScreen: Screens.SelectProvider, + navigateProps: { + amount: { crypto: 100, fiat: 154 }, + flow: 'CashIn', + tokenId: 'arbitrum-sepolia:0x123', + }, + }, + { + actionName: TokenActionName.Transfer, + actionTitle: 'earnFlow.addCryptoBottomSheet.actions.transfer', + navigateScreen: Screens.ExchangeQR, + navigateProps: { exchanges: [], flow: 'CashIn' }, + }, + { + actionName: TokenActionName.Swap, + actionTitle: 'earnFlow.addCryptoBottomSheet.actions.swap', + navigateScreen: Screens.SwapScreenWithBack, + navigateProps: { toTokenId: 'arbitrum-sepolia:0x123' }, + }, + ])( + 'triggers the correct analytics and navigation for $actionName', + async ({ actionName, actionTitle, navigateScreen, navigateProps }) => { + const { getByText } = render( + + + + ) + + fireEvent.press(getByText(actionTitle)) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_add_crypto_action_press, { + action: actionName, + address: '0x123', + balanceUsd: 5.8, + networkId: mockArbitrumUsdcBalance.networkId, + symbol: mockArbitrumUsdcBalance.symbol, + tokenId: 'arbitrum-sepolia:0x123', + }) + + expect(navigate).toHaveBeenCalledWith(navigateScreen, navigateProps) + } + ) +}) diff --git a/src/earn/EarnAddCryptoBottomSheet.tsx b/src/earn/EarnAddCryptoBottomSheet.tsx new file mode 100644 index 00000000000..45a8cda7d47 --- /dev/null +++ b/src/earn/EarnAddCryptoBottomSheet.tsx @@ -0,0 +1,164 @@ +import BigNumber from 'bignumber.js' +import React, { RefObject } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, Text, View } from 'react-native' +import { useSelector } from 'react-redux' +import { EarnEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import BottomSheet, { BottomSheetRefType } from 'src/components/BottomSheet' +import Touchable from 'src/components/Touchable' +import { CICOFlow } from 'src/fiatExchanges/utils' +import QuickActionsAdd from 'src/icons/quick-actions/Add' +import QuickActionsSend from 'src/icons/quick-actions/Send' +import QuickActionsSwap from 'src/icons/quick-actions/Swap' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import { isAppSwapsEnabledSelector } from 'src/navigator/selectors' +import { NETWORK_NAMES } from 'src/shared/conts' +import { Colors } from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { Spacing } from 'src/styles/styles' +import { useCashInTokens, useSwappableTokens, useTokenToLocalAmount } from 'src/tokens/hooks' +import { TokenBalance } from 'src/tokens/slice' +import { TokenActionName } from 'src/tokens/types' +import { getTokenAnalyticsProps } from 'src/tokens/utils' + +export default function EarnAddCryptoBottomSheet({ + forwardedRef, + token, + tokenAmount, +}: { + forwardedRef: RefObject + token: TokenBalance + tokenAmount: BigNumber +}) { + const { t } = useTranslation() + const { swappableFromTokens } = useSwappableTokens() + const cashInTokens = useCashInTokens() + const isSwapEnabled = useSelector(isAppSwapsEnabledSelector) + + const showAdd = !!cashInTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId) + const showSwap = + isSwapEnabled && + !!swappableFromTokens.find( + (tokenInfo) => tokenInfo.networkId === token.networkId && tokenInfo.tokenId !== token.tokenId + ) + const addAmount = { + crypto: tokenAmount.toNumber(), + fiat: Math.round( + (useTokenToLocalAmount(tokenAmount, token.tokenId) || new BigNumber(0)).toNumber() + ), + } + + const actions = [ + { + name: TokenActionName.Add, + title: t('earnFlow.addCryptoBottomSheet.actions.add'), + details: t('earnFlow.addCryptoBottomSheet.actionDescriptions.add', { + tokenSymbol: token.symbol, + tokenNetwork: NETWORK_NAMES[token.networkId], + }), + iconComponent: QuickActionsAdd, + onPress: () => { + navigate(Screens.SelectProvider, { + tokenId: token.tokenId, + flow: CICOFlow.CashIn, + amount: addAmount, + }) + }, + visible: showAdd, + }, + { + name: TokenActionName.Transfer, + title: t('earnFlow.addCryptoBottomSheet.actions.transfer'), + details: t('earnFlow.addCryptoBottomSheet.actionDescriptions.transfer', { + tokenSymbol: token.symbol, + tokenNetwork: NETWORK_NAMES[token.networkId], + }), + iconComponent: QuickActionsSend, + onPress: () => { + navigate(Screens.ExchangeQR, { flow: CICOFlow.CashIn, exchanges: [] }) + }, + visible: true, + }, + { + name: TokenActionName.Swap, + title: t('earnFlow.addCryptoBottomSheet.actions.swap'), + details: t('earnFlow.addCryptoBottomSheet.actionDescriptions.swap', { + tokenSymbol: token.symbol, + tokenNetwork: NETWORK_NAMES[token.networkId], + }), + iconComponent: QuickActionsSwap, + onPress: () => { + navigate(Screens.SwapScreenWithBack, { toTokenId: token.tokenId }) + }, + visible: showSwap, + }, + ].filter((action) => action.visible) + + return ( + + + {actions.map((action) => ( + { + ValoraAnalytics.track(EarnEvents.earn_add_crypto_action_press, { + action: action.name, + ...getTokenAnalyticsProps(token), + }) + action.onPress() + }} + testID={`Earn/AddCrypto/${action.name}`} + > + <> + + + {action.title} + {action.details} + + + + ))} + + + ) +} + +const styles = StyleSheet.create({ + actionsContainer: { + flex: 1, + gap: Spacing.Regular16, + marginVertical: Spacing.Thick24, + }, + actionTitle: { + ...typeScale.labelMedium, + color: Colors.black, + }, + actionDetails: { + ...typeScale.bodySmall, + color: Colors.black, + }, + title: { + ...typeScale.titleSmall, + color: Colors.black, + }, + touchable: { + backgroundColor: Colors.gray1, + padding: Spacing.Regular16, + flexDirection: 'row', + gap: Spacing.Regular16, + alignItems: 'center', + }, +}) diff --git a/src/tokens/TokenDetails.tsx b/src/tokens/TokenDetails.tsx index 574a0315f3e..047c2303a93 100644 --- a/src/tokens/TokenDetails.tsx +++ b/src/tokens/TokenDetails.tsx @@ -47,7 +47,7 @@ import { } from 'src/tokens/hooks' import { sortedTokensWithBalanceSelector } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' -import { TokenDetailsAction, TokenDetailsActionName } from 'src/tokens/types' +import { TokenAction, TokenActionName } from 'src/tokens/types' import { getSupportedNetworkIdsForSend, getTokenAnalyticsProps, @@ -174,7 +174,7 @@ export const useActions = (token: TokenBalance) => { return [ { - name: TokenDetailsActionName.Send, + name: TokenActionName.Send, title: t('tokenDetails.actions.send'), details: t('tokenDetails.actionDescriptions.sendV1_74', { supportedNetworkNames: supportedNetworkIdsForSend @@ -189,7 +189,7 @@ export const useActions = (token: TokenBalance) => { visible: !!sendableTokensWithBalance.find((tokenInfo) => tokenInfo.tokenId === token.tokenId), }, { - name: TokenDetailsActionName.Swap, + name: TokenActionName.Swap, title: t('tokenDetails.actions.swap'), details: t('tokenDetails.actionDescriptions.swap'), iconComponent: QuickActionsSwap, @@ -201,7 +201,7 @@ export const useActions = (token: TokenBalance) => { !!swappableFromTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId), }, { - name: TokenDetailsActionName.Add, + name: TokenActionName.Add, title: t('tokenDetails.actions.add'), details: t('tokenDetails.actionDescriptions.add'), iconComponent: QuickActionsAdd, @@ -215,7 +215,7 @@ export const useActions = (token: TokenBalance) => { visible: !!cashInTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId), }, { - name: TokenDetailsActionName.Withdraw, + name: TokenActionName.Withdraw, title: t('tokenDetails.actions.withdraw'), details: t('tokenDetails.actionDescriptions.withdraw'), iconComponent: QuickActionsWithdraw, @@ -234,14 +234,14 @@ function Actions({ }: { token: TokenBalance bottomSheetRef: React.RefObject - actions: TokenDetailsAction[] + actions: TokenAction[] }) { const { t } = useTranslation() const cashOutTokens = useCashOutTokens() const showWithdraw = !!cashOutTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId) const moreAction = { - name: TokenDetailsActionName.More, + name: TokenActionName.More, title: t('tokenDetails.actions.more'), iconComponent: QuickActionsMore, onPress: () => { diff --git a/src/tokens/TokenDetailsMoreActions.test.tsx b/src/tokens/TokenDetailsMoreActions.test.tsx index 558bfa364fb..c1659615cb5 100644 --- a/src/tokens/TokenDetailsMoreActions.test.tsx +++ b/src/tokens/TokenDetailsMoreActions.test.tsx @@ -8,7 +8,7 @@ import QuickActionsSend from 'src/icons/quick-actions/Send' import QuickActionsSwap from 'src/icons/quick-actions/Swap' import TokenDetailsMoreActions from 'src/tokens/TokenDetailsMoreActions' import { StoredTokenBalance, TokenBalance } from 'src/tokens/slice' -import { TokenDetailsAction, TokenDetailsActionName } from 'src/tokens/types' +import { TokenAction, TokenActionName } from 'src/tokens/types' import { NetworkId } from 'src/transactions/types' import { mockCeloAddress, mockCeloTokenId } from 'test/values' @@ -39,9 +39,9 @@ const mockCeloBalance: TokenBalance = { priceUsd: new BigNumber(mockStoredCeloTokenBalance.priceUsd!), } -const mockActions: TokenDetailsAction[] = [ +const mockActions: TokenAction[] = [ { - name: TokenDetailsActionName.Send, + name: TokenActionName.Send, title: 'tokenDetails.actions.send', details: 'tokenDetails.actions.sendDetails', iconComponent: QuickActionsSend, @@ -49,7 +49,7 @@ const mockActions: TokenDetailsAction[] = [ visible: true, }, { - name: TokenDetailsActionName.Swap, + name: TokenActionName.Swap, title: 'tokenDetails.actions.swap', details: 'tokenDetails.actions.swapDetails', iconComponent: QuickActionsSwap, @@ -57,7 +57,7 @@ const mockActions: TokenDetailsAction[] = [ visible: true, }, { - name: TokenDetailsActionName.Add, + name: TokenActionName.Add, title: 'tokenDetails.actions.add', details: 'tokenDetails.actions.addDetails', iconComponent: QuickActionsAdd, @@ -65,7 +65,7 @@ const mockActions: TokenDetailsAction[] = [ visible: true, }, { - name: TokenDetailsActionName.Withdraw, + name: TokenActionName.Withdraw, title: 'tokenDetails.actions.withdraw', details: 'tokenDetails.actions.withdrawDetails', iconComponent: QuickActionsSend, diff --git a/src/tokens/TokenDetailsMoreActions.tsx b/src/tokens/TokenDetailsMoreActions.tsx index 3fcdb55336c..d9544c7e262 100644 --- a/src/tokens/TokenDetailsMoreActions.tsx +++ b/src/tokens/TokenDetailsMoreActions.tsx @@ -9,7 +9,7 @@ import { Colors } from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import { TokenBalance } from 'src/tokens/slice' -import { TokenDetailsAction } from 'src/tokens/types' +import { TokenAction } from 'src/tokens/types' import { getTokenAnalyticsProps } from 'src/tokens/utils' export default function TokenDetailsMoreActions({ @@ -18,7 +18,7 @@ export default function TokenDetailsMoreActions({ token, }: { forwardedRef: RefObject - actions: TokenDetailsAction[] + actions: TokenAction[] token: TokenBalance }) { const { t } = useTranslation() diff --git a/src/tokens/types.ts b/src/tokens/types.ts index 0186fbd4694..c06c1f74787 100644 --- a/src/tokens/types.ts +++ b/src/tokens/types.ts @@ -1,15 +1,16 @@ import Colors from 'src/styles/colors' -export enum TokenDetailsActionName { +export enum TokenActionName { Send = 'Send', Swap = 'Swap', Add = 'Add', Withdraw = 'Withdraw', More = 'More', + Transfer = 'Transfer', } -export interface TokenDetailsAction { - name: TokenDetailsActionName +export interface TokenAction { + name: TokenActionName title: string details: string iconComponent: React.MemoExoticComponent<({ color }: { color: Colors }) => JSX.Element>