From 7d421cf781a193da14913bb910408d502baae112 Mon Sep 17 00:00:00 2001 From: Finnian Jacobson-Schulte <140328381+finnian0826@users.noreply.github.com> Date: Fri, 10 May 2024 04:40:44 +1200 Subject: [PATCH] feat(earn): Add Crypto bottom sheet (#5376) ### Description Creates new component for the Earn "Add Crypto" bottom sheet. This component should be used in the same way as the TokenDetailsMoreActions component [is used](https://github.com/valora-inc/wallet/blob/6df3859e7271db5e643aba11eda8cde19151135d/src/tokens/TokenDetails.tsx#L119). Decided to make a new component as to avoid adding extra props to decide copy, choose actions, and add an amount prop which would not be used in the TokenDetailsMoreActions case. Talked with product and Nitya and confirmed that: - On the QR code page, have title be "Deposit Crypto" like in the cash-in exchange case - Use "Arbitrum One" as the network name in copy Note: Price is not passed to the swap component, there is not support for that and based on discussion in quick sync it seems hard. ### Test plan Unit tests added. **Manual testing:** Bottom sheet: ![bottom-sheet](https://github.com/valora-inc/wallet/assets/140328381/0a029234-6bba-46dd-8512-6a73a12b660a) Tapping Buy: https://github.com/valora-inc/wallet/assets/140328381/2a8985ca-9ae2-41e1-b0f7-abed4071b723 Tapping Transfer: https://github.com/valora-inc/wallet/assets/140328381/61e38183-d641-43c8-9d2d-39af4236215d Tapping Swap: https://github.com/valora-inc/wallet/assets/140328381/393d00a6-41d7-49fc-a0f7-162e1ed15e71 Tapping ### Related issues - Fixes #ACT-1177 ### Backwards compatibility Yes, new component so not changing past stuff ### Network scalability If a new NetworkId and/or Network are added in the future, the changes in this PR will: - [X] Continue to work without code changes, OR trigger a compilation error (guaranteeing we find it when a new network is added) --- locales/base/translation.json | 14 ++ src/analytics/Events.tsx | 1 + src/analytics/Properties.tsx | 9 +- src/analytics/docs.ts | 1 + src/earn/EarnAddCryptoBottomSheet.test.tsx | 190 ++++++++++++++++++++ src/earn/EarnAddCryptoBottomSheet.tsx | 164 +++++++++++++++++ src/tokens/TokenDetails.tsx | 14 +- src/tokens/TokenDetailsMoreActions.test.tsx | 12 +- src/tokens/TokenDetailsMoreActions.tsx | 4 +- src/tokens/types.ts | 7 +- 10 files changed, 395 insertions(+), 21 deletions(-) create mode 100644 src/earn/EarnAddCryptoBottomSheet.test.tsx create mode 100644 src/earn/EarnAddCryptoBottomSheet.tsx 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>