From eee9d34d926d9b305c6d8fef06fc00a8d6b0f471 Mon Sep 17 00:00:00 2001 From: Finnian Jacobson-Schulte <140328381+finnian0826@users.noreply.github.com> Date: Thu, 25 Jul 2024 09:43:01 +1200 Subject: [PATCH] feat(earn): Add EarnHome screen (#5656) ### Description Move over necessary multi-pool earn stuff from earnV2. Two pools shown for testing purposes but the PR only includes the single Aave pool currently supported. Will add analytics and hook stuff up in follow-up PRs ### Test plan https://github.com/user-attachments/assets/c6a76424-367f-4d26-84ec-7e0343dd50c0 | Open pools | My pools | Bottom sheet | | ----- | ----- | ----- | | ![open](https://github.com/user-attachments/assets/4e9b6d0a-9bfb-40e3-bf96-44341ef116af) | ![my](https://github.com/user-attachments/assets/d5ff397a-5f80-4d40-9523-5e5194cb48da) | ![bottom-sheet](https://github.com/user-attachments/assets/6d739afc-0885-4eff-9ecc-273bdfeaf9aa) | ### Related issues - Part of ACT-1260 ### Backwards compatibility Yes ### 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) --------- Co-authored-by: Satish Ravi --- locales/base/translation.json | 29 +- src/components/FilterChipsCarousel.tsx | 16 +- src/components/TokenBottomSheet.tsx | 5 + src/earn/EarnHome.test.tsx | 268 +++++++++++++++++ src/earn/EarnHome.tsx | 382 +++++++++++++++++++++++++ src/earn/EarnTabBar.test.tsx | 39 +++ src/earn/EarnTabBar.tsx | 66 +++++ src/earn/PoolCard.test.tsx | 160 +++++++++++ src/earn/PoolCard.tsx | 194 +++++++++++++ src/earn/PoolList.tsx | 79 +++++ src/earn/pools.ts | 22 ++ src/earn/types.ts | 22 +- src/navigator/Navigator.tsx | 2 + src/navigator/Screens.tsx | 1 + src/navigator/types.tsx | 2 + test/RootStateSchema.json | 1 + 16 files changed, 1283 insertions(+), 5 deletions(-) create mode 100644 src/earn/EarnHome.test.tsx create mode 100644 src/earn/EarnHome.tsx create mode 100644 src/earn/EarnTabBar.test.tsx create mode 100644 src/earn/EarnTabBar.tsx create mode 100644 src/earn/PoolCard.test.tsx create mode 100644 src/earn/PoolCard.tsx create mode 100644 src/earn/PoolList.tsx create mode 100644 src/earn/pools.ts diff --git a/locales/base/translation.json b/locales/base/translation.json index 657e61df405..26a4804a7db 100644 --- a/locales/base/translation.json +++ b/locales/base/translation.json @@ -2015,7 +2015,8 @@ "network": "{{networkName}} Network", "selectNetwork": "Network", "stablecoins": "Stablecoins", - "gasTokens": "Gas Tokens" + "gasTokens": "Gas Tokens", + "tokens": "Token" } }, "homeActions": { @@ -2571,6 +2572,32 @@ "earnWithdrawTitle": "Withdrew", "earnWithdrawSubtitle": "Withdrew {{tokenSymbol}} from {{providerName}} Pool", "earnWithdrawDetails": "Amount Withdrawn" + }, + "home": { + "title": "Earn", + "learnMore": "<0>Learn more about yield pools.", + "learnMoreBottomSheet": { + "bottomSheetTitle": "Learn more about yield pools", + "apySubtitle": "What is an APY?", + "apyDescription": "Annual percentage yield (APY) is a metric used to calculate the annualized return on crypto investments. It's a key indicator of a cryptocurrency's potential return and profitability.", + "tvlSubtitle": "What is TVL?", + "tvlDescription": "TVL stands for Total Value Locked, and it's a metric used in the crypto industry to measure the value of digital assets locked on a blockchain network. TVL is calculated by adding up the value of all digital assets locked in a DeFi protocol or smart contract. These assets can include cryptocurrencies, stablecoins, and other tokens." + } + }, + "poolCard": { + "onNetwork": "on {{networkName}}", + "rate": "Rate (est.)", + "reward": "Reward", + "tvl": "TVL", + "exitPool": "Exit Pool", + "addToPool": "Add to Pool", + "apy": "{{apy}}% APY", + "poweredBy": "Powered by {{providerName}}", + "deposited": "Supplied" + }, + "poolFilters": { + "openPools": "Open Pools", + "myPools": "My Pools" } } } diff --git a/src/components/FilterChipsCarousel.tsx b/src/components/FilterChipsCarousel.tsx index b0d6bf9be43..2ce0a44aab7 100644 --- a/src/components/FilterChipsCarousel.tsx +++ b/src/components/FilterChipsCarousel.tsx @@ -23,11 +23,20 @@ export interface NetworkFilterChip extends BaseFilterChip { selectedNetworkIds: NetworkId[] } +export interface TokenSelectFilterChip extends BaseFilterChip { + filterFn: (t: T, tokenId: string) => boolean + selectedTokenId: string +} + export function isNetworkChip(chip: FilterChip): chip is NetworkFilterChip { return 'allNetworkIds' in chip } -export type FilterChip = BooleanFilterChip | NetworkFilterChip +export function isTokenSelectChip(chip: FilterChip): chip is TokenSelectFilterChip { + return 'selectedTokenId' in chip +} + +export type FilterChip = BooleanFilterChip | NetworkFilterChip | TokenSelectFilterChip interface Props { chips: FilterChip[] @@ -35,6 +44,7 @@ interface Props { primaryColor: colors secondaryColor: colors style?: StyleProp + contentContainerStyle?: StyleProp forwardedRef?: React.RefObject scrollEnabled?: boolean } @@ -45,6 +55,7 @@ function FilterChipsCarousel({ primaryColor, secondaryColor, style, + contentContainerStyle, forwardedRef, scrollEnabled = true, }: Props) { @@ -57,6 +68,7 @@ function FilterChipsCarousel({ contentContainerStyle={[ styles.contentContainer, { flexWrap: scrollEnabled ? 'nowrap' : 'wrap', width: scrollEnabled ? 'auto' : '100%' }, + contentContainerStyle, ]} ref={forwardedRef} testID="FilterChipsCarousel" @@ -87,7 +99,7 @@ function FilterChipsCarousel({ > {chip.name} - {isNetworkChip(chip) && ( + {(isNetworkChip(chip) || isTokenSelectChip(chip)) && ( { + beforeEach(() => { + jest.clearAllMocks() + }) + it('renders open pools correctly', () => { + jest.mocked(getPools).mockReturnValue(mockPools) + const mockPoolToken = mockPoolTokenUSDC + const { getByTestId, getAllByTestId } = render( + + + + ) + + expect(getByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeTruthy() + expect(getByTestId(`PoolCard/${mockPools[1].poolId}`)).toBeTruthy() + + const tabItems = getAllByTestId('Earn/TabBarItem') + expect(tabItems).toHaveLength(2) + expect(tabItems[0]).toHaveTextContent('openPools') + expect(tabItems[1]).toHaveTextContent('myPools') + }) + + it('correctly shows pool under my pools if has balance', () => { + jest.mocked(getPools).mockReturnValue(mockPools) + const mockPoolToken = { + ...mockPoolTokenUSDC, + balance: '10', + } + const { getByTestId, queryByTestId, getByText } = render( + + + + ) + + expect(queryByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeFalsy() + fireEvent.press(getByText('earnFlow.poolFilters.myPools')) + expect(getByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeTruthy() + }) + + it('correctly shows correct networks, tokens under filters', () => { + jest.mocked(getPools).mockReturnValue(mockPools) + const mockPoolToken = mockPoolTokenUSDC + const { getByTestId, getAllByTestId, getByText } = render( + + + + ) + + fireEvent.press(getByText('tokenBottomSheet.filters.selectNetwork')) + expect(getByTestId('Arbitrum Sepolia-icon')).toBeTruthy() + + fireEvent.press(getByText('tokenBottomSheet.filters.tokens')) + expect(getAllByTestId('TokenBalanceItem')).toHaveLength(2) + }) + + it('shows correct pool when filtering by token', () => { + jest.mocked(getPools).mockReturnValue(mockPools) + const { getByTestId, getByText, queryByTestId } = render( + + + + ) + + expect(getByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeTruthy() + expect(getByTestId(`PoolCard/${mockPools[1].poolId}`)).toBeTruthy() + + fireEvent.press(getByText('tokenBottomSheet.filters.tokens')) + fireEvent.press(getByTestId('USDCSymbol')) + + expect(getByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeTruthy() + expect(queryByTestId(`PoolCard/${mockPools[1].poolId}`)).toBeFalsy() + }) + + it('shows correct pool when filtering by network', () => { + const mockPoolsForNetworkFilter: Pool[] = [ + mockPools[0], + { + poolId: 'aEthWETH', + networkId: NetworkId['ethereum-sepolia'], + tokens: [mockEthTokenId], + depositTokenId: mockEthTokenId, + poolTokenId: `${NetworkId['ethereum-sepolia']}:0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8`, + poolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD', + apy: 0.023, + reward: 0, + tvl: 411_630_000, + provider: 'Aave', + }, + ] + jest.mocked(getPools).mockReturnValue(mockPoolsForNetworkFilter) + const { getByTestId, getByText, queryByTestId } = render( + + + + ) + + expect(getByTestId(`PoolCard/${mockPoolsForNetworkFilter[0].poolId}`)).toBeTruthy() + expect(getByTestId(`PoolCard/${mockPoolsForNetworkFilter[1].poolId}`)).toBeTruthy() + + fireEvent.press(getByText('tokenBottomSheet.filters.selectNetwork')) + fireEvent.press(getByTestId('Arbitrum Sepolia-icon')) + + expect(getByTestId(`PoolCard/${mockPoolsForNetworkFilter[0].poolId}`)).toBeTruthy() + expect(queryByTestId(`PoolCard/${mockPoolsForNetworkFilter[1].poolId}`)).toBeFalsy() + }) +}) diff --git a/src/earn/EarnHome.tsx b/src/earn/EarnHome.tsx new file mode 100644 index 00000000000..233046dc63b --- /dev/null +++ b/src/earn/EarnHome.tsx @@ -0,0 +1,382 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { default as React, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LayoutChangeEvent, StyleSheet, Text, View } from 'react-native' +import { ScrollView } from 'react-native-gesture-handler' +import Animated, { + interpolateColor, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import BottomSheet, { BottomSheetRefType } from 'src/components/BottomSheet' +import FilterChipsCarousel, { + FilterChip, + NetworkFilterChip, + TokenSelectFilterChip, + isNetworkChip, + isTokenSelectChip, +} from 'src/components/FilterChipsCarousel' +import TokenBottomSheet, { TokenPickerOrigin } from 'src/components/TokenBottomSheet' +import NetworkMultiSelectBottomSheet from 'src/components/multiSelect/NetworkMultiSelectBottomSheet' +import EarnTabBar from 'src/earn/EarnTabBar' +import PoolList from 'src/earn/PoolList' +import { getPools } from 'src/earn/pools' +import { EarnTabType } from 'src/earn/types' +import { Screens } from 'src/navigator/Screens' +import useScrollAwareHeader from 'src/navigator/ScrollAwareHeader' +import { StackParamList } from 'src/navigator/types' +import { useSelector } from 'src/redux/hooks' +import { Colors } from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { Shadow, Spacing, getShadowStyle } from 'src/styles/styles' +import { tokensByIdSelector } from 'src/tokens/selectors' +import { TokenBalance } from 'src/tokens/slice' +import { NetworkId } from 'src/transactions/types' + +const HEADER_OPACITY_ANIMATION_START_OFFSET = 44 +const HEADER_OPACITY_ANIMATION_DISTANCE = 20 + +type Props = NativeStackScreenProps + +function useFilterChips(): FilterChip[] { + const { t } = useTranslation() + + const pools = getPools() + const supportedNetworkIds = [...new Set(pools.map((pool) => pool.networkId))] + const tokens = [...new Set(pools.flatMap((pool) => pool.tokens))] + const networkChipConfig: NetworkFilterChip = { + id: 'network-ids', + name: t('tokenBottomSheet.filters.selectNetwork'), + filterFn: (token: TokenBalance, selected?: NetworkId[]) => { + return !!selected && selected.includes(token.networkId) + }, + isSelected: false, + allNetworkIds: supportedNetworkIds, + selectedNetworkIds: supportedNetworkIds, + } + + const tokensChipConfig: TokenSelectFilterChip = { + id: 'token-select', + name: t('tokenBottomSheet.filters.tokens'), + filterFn: (token: TokenBalance, tokenId: string) => token.tokenId === tokenId, + selectedTokenId: tokens[0], + isSelected: false, + } + + return [networkChipConfig, tokensChipConfig] +} + +export default function EarnHome({ navigation, route }: Props) { + const { t } = useTranslation() + const filterChipsCarouselRef = useRef(null) + const pools = getPools() + + const activeTab = route.params?.activeEarnTab ?? EarnTabType.OpenPools + + const insets = useSafeAreaInsets() + + const supportedNetworkIds = [...new Set(pools.map((pool) => pool.networkId))] + const allTokens = useSelector((state) => tokensByIdSelector(state, supportedNetworkIds)) + + // Scroll Aware Header + const scrollPosition = useSharedValue(0) + const [listHeaderHeight, setListHeaderHeight] = useState(0) + const [nonStickyHeaderHeight, setNonStickyHeaderHeight] = useState(0) + + const animatedListHeaderStyles = useAnimatedStyle(() => { + if (nonStickyHeaderHeight === 0) { + return { + shadowColor: 'transparent', + transform: [ + { + translateY: -scrollPosition.value, + }, + ], + } + } + + return { + transform: [ + { + translateY: + scrollPosition.value > nonStickyHeaderHeight + ? -nonStickyHeaderHeight + : -scrollPosition.value, + }, + ], + shadowColor: interpolateColor( + scrollPosition.value, + [nonStickyHeaderHeight - 10, nonStickyHeaderHeight + 10], + ['transparent', 'rgba(48, 46, 37, 0.15)'] + ), + } + }, [scrollPosition.value, nonStickyHeaderHeight]) + + const networkChipRef = useRef(null) + const tokenBottomSheetRef = useRef(null) + const learnMoreBottomSheetRef = useRef(null) + + // The NetworkMultiSelectBottomSheet and TokenBottomSheet must be rendered at this level in order to be in + // front of the bottom tabs navigator when they render. So, we need to manage the state of the filters here and pass them down + // This is not ideal, and we should be wary of how this affects the performance of the home tabs since it renders + // on all of them, not just the Earn tab. + const chips = useFilterChips() + const [filters, setFilters] = useState(chips) + const activeFilters = useMemo(() => filters.filter((filter) => filter.isSelected), [filters]) + const networkChip = useMemo( + () => filters.find((chip): chip is NetworkFilterChip => isNetworkChip(chip)), + [filters] + ) + const tokens = [...new Set(pools.flatMap((pool) => pool.tokens))] + + const tokensInfo = useMemo(() => { + return tokens + .map((tokenId) => allTokens[tokenId]) + .filter((token): token is TokenBalance => !!token) + }, [allTokens]) + + const tokenList = useMemo(() => { + return tokensInfo.filter((token) => { + // Exclude the token if it does not match the active filters + if ( + !activeFilters.every((filter) => { + if (isNetworkChip(filter)) { + return filter.filterFn(token, filter.selectedNetworkIds) + } + if (isTokenSelectChip(filter)) { + return filter.filterFn(token, filter.selectedTokenId) + } + return filter.filterFn(token) + }) + ) { + return false + } + + return true + }) + }, [tokensInfo, activeFilters]) + + const handleToggleFilterChip = (chip: FilterChip) => { + if (isNetworkChip(chip)) { + return networkChipRef.current?.snapToIndex(0) + } + return tokenBottomSheetRef.current?.snapToIndex(0) + } + + // These function params mimic the params of the setSelectedNetworkIds function in + // const [selectedNetworkIds, setSelectedNetworkIds] = useState([]) + // This custom function is used to keep the same shared state between the network filter and the other filters + // which made the rest of the code more readable and maintainable + const setSelectedNetworkIds = (arg: NetworkId[] | ((networkIds: NetworkId[]) => NetworkId[])) => { + setFilters((prev) => { + return prev.map((chip) => { + if (isNetworkChip(chip)) { + const selectedNetworkIds = typeof arg === 'function' ? arg(chip.selectedNetworkIds) : arg + return { + ...chip, + selectedNetworkIds, + isSelected: selectedNetworkIds.length !== chip.allNetworkIds.length, + } + } + return { + ...chip, + isSelected: false, + } + }) + }) + } + + const onTokenPressed = (token: TokenBalance) => { + setFilters((prev) => { + return prev.map((chip) => { + if (isTokenSelectChip(chip)) { + return { + ...chip, + selectedTokenId: token.tokenId, + isSelected: true, + } + } + return { + ...chip, + isSelected: false, + } + }) + }) + tokenBottomSheetRef.current?.close() + } + + const handleMeasureListHeadereHeight = (event: LayoutChangeEvent) => { + setListHeaderHeight(event.nativeEvent.layout.height) + } + + const handleScroll = useAnimatedScrollHandler((event) => { + scrollPosition.value = event.contentOffset.y + }) + + const handleMeasureNonStickyHeaderHeight = (event: LayoutChangeEvent) => { + setNonStickyHeaderHeight(event.nativeEvent.layout.height) + } + + useScrollAwareHeader({ + navigation, + title: t('earnFlow.home.title'), + scrollPosition, + startFadeInPosition: nonStickyHeaderHeight - HEADER_OPACITY_ANIMATION_START_OFFSET, + animationDistance: HEADER_OPACITY_ANIMATION_DISTANCE, + }) + + const handleChangeActiveView = (selectedTab: EarnTabType) => { + navigation.setParams({ activeEarnTab: selectedTab }) + } + + const displayPools = useMemo(() => { + return pools.filter((pool) => { + const poolTokenInfo = allTokens[pool.poolTokenId] + const depositTokenInfo = allTokens[pool.depositTokenId] + const isMyPool = poolTokenInfo?.balance.gt(0) && !!depositTokenInfo + return activeTab === EarnTabType.MyPools ? isMyPool : !isMyPool + }) + }, [pools, allTokens, activeTab]) + + const onPressLearnMore = () => { + learnMoreBottomSheetRef.current?.snapToIndex(0) + } + + return ( + <> + + + + + {t('earnFlow.home.title')} + + + + + + + + pool.tokens.some((token) => tokenList.map((token) => token.tokenId).includes(token)) + )} + onPressLearnMore={onPressLearnMore} + /> + + + {networkChip && ( + + )} + + + ) +} + +function LearnMoreBottomSheet({ + learnMoreBottomSheetRef, +}: { + learnMoreBottomSheetRef: React.RefObject +}) { + const { t } = useTranslation() + + return ( + + + {t('earnFlow.home.learnMoreBottomSheet.apySubtitle')} + + + {t('earnFlow.home.learnMoreBottomSheet.apyDescription')} + + + {t('earnFlow.home.learnMoreBottomSheet.tvlSubtitle')} + + + {t('earnFlow.home.learnMoreBottomSheet.tvlDescription')} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + title: { + ...typeScale.titleMedium, + color: Colors.black, + }, + listHeaderContainer: { + ...getShadowStyle(Shadow.SoftLight), + paddingBottom: Spacing.Regular16, + paddingHorizontal: Spacing.Regular16, + backgroundColor: Colors.white, + position: 'absolute', + width: '100%', + zIndex: 1, + }, + nonStickyHeaderContainer: { + zIndex: 1, + gap: Spacing.Thick24, + flexDirection: 'column', + }, + headerRow: { + flexDirection: 'row', + }, + filterChipsCarouselContainer: { + flexDirection: 'row', + }, + contentContainerStyle: { + justifyContent: 'flex-end', + }, + learnMoreTitle: { + ...typeScale.titleSmall, + colors: Colors.black, + }, + learnMoreSubTitle: { + ...typeScale.labelSemiBoldSmall, + colors: Colors.black, + marginBottom: Spacing.Tiny4, + }, + learnMoreDescription: { + ...typeScale.bodySmall, + colors: Colors.black, + marginBottom: Spacing.Thick24, + }, +}) diff --git a/src/earn/EarnTabBar.test.tsx b/src/earn/EarnTabBar.test.tsx new file mode 100644 index 00000000000..16516c55f87 --- /dev/null +++ b/src/earn/EarnTabBar.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render } from '@testing-library/react-native' +import React from 'react' +import EarnTabBar from 'src/earn/EarnTabBar' +import { EarnTabType } from 'src/earn/types' +import Colors from 'src/styles/colors' + +describe('EarnTabBar', () => { + const onChange = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders all tab bar items', () => { + const { getAllByTestId } = render( + + ) + + const tabItems = getAllByTestId('Earn/TabBarItem') + expect(tabItems).toHaveLength(2) + expect(tabItems[0]).toHaveTextContent('openPools') + expect(tabItems[0].children[0]).toHaveStyle({ color: Colors.black }) + expect(tabItems[1]).toHaveTextContent('myPools') + expect(tabItems[1].children[0]).toHaveStyle({ color: Colors.gray4 }) + }) + + it.each([{ tab: EarnTabType.OpenPools }, { tab: EarnTabType.MyPools }])( + 'selecting tab $tab invokes on change', + ({ tab }) => { + const { getAllByTestId } = render( + + ) + + fireEvent.press(getAllByTestId('Earn/TabBarItem')[tab]) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(tab) + } + ) +}) diff --git a/src/earn/EarnTabBar.tsx b/src/earn/EarnTabBar.tsx new file mode 100644 index 00000000000..cdd91985235 --- /dev/null +++ b/src/earn/EarnTabBar.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, Text, View } from 'react-native' +import Touchable from 'src/components/Touchable' +import { EarnTabType } from 'src/earn/types' +import Colors from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { vibrateInformative } from 'src/styles/hapticFeedback' +import { Spacing } from 'src/styles/styles' + +export default function EarnTabBar({ + activeTab, + onChange, +}: { + activeTab: EarnTabType + onChange: (selectedTab: EarnTabType) => void +}) { + const { t } = useTranslation() + + const items = [t('earnFlow.poolFilters.openPools'), t('earnFlow.poolFilters.myPools')] + + const handleSelectOption = (index: EarnTabType) => () => { + onChange(index) + vibrateInformative() + } + + return ( + + {items.map((value, index) => ( + + + {value} + + + ))} + + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + gap: Spacing.Regular16, + }, + underline: { + borderBottomWidth: 2, + borderBottomColor: Colors.black, + }, + touchable: { + flexShrink: 1, + paddingBottom: Spacing.Tiny4, + }, + item: { + ...typeScale.bodyMedium, + color: Colors.gray4, + }, + itemSelected: { + ...typeScale.labelMedium, + color: Colors.black, + }, +}) diff --git a/src/earn/PoolCard.test.tsx b/src/earn/PoolCard.test.tsx new file mode 100644 index 00000000000..4cb91e3e0fe --- /dev/null +++ b/src/earn/PoolCard.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, render } from '@testing-library/react-native' +import React from 'react' +import { Provider } from 'react-redux' +import PoolCard from 'src/earn/PoolCard' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import { NetworkId } from 'src/transactions/types' +import { createMockStore } from 'test/utils' +import { mockArbEthTokenId, mockArbUsdcTokenId, mockTokenBalances } from 'test/values' + +describe('PoolCard', () => { + it('renders correctly', () => { + const { getByText } = render( + + + + ) + + expect(getByText('USDC / ETH')).toBeTruthy() + expect( + getByText('earnFlow.poolCard.onNetwork, {"networkName":"Arbitrum Sepolia"}') + ).toBeTruthy() + expect(getByText('earnFlow.poolCard.apy, {"apy":"3.30"}')).toBeTruthy() + expect(getByText('0.00%')).toBeTruthy() + expect(getByText('$1,360,000')).toBeTruthy() + }) + + it('navigates to enter amount when no pool balance', () => { + const { getByText } = render( + + + + ) + + expect(getByText('earnFlow.poolCard.addToPool')).toBeTruthy() + fireEvent.press(getByText('earnFlow.poolCard.addToPool')) + expect(navigate).toHaveBeenCalledWith(Screens.EarnEnterAmount, { tokenId: mockArbUsdcTokenId }) + }) + it('navigates to enter amount when have pool balance', () => { + const { getByText } = render( + + + + ) + + expect(getByText('earnFlow.poolCard.addToPool')).toBeTruthy() + fireEvent.press(getByText('earnFlow.poolCard.addToPool')) + expect(navigate).toHaveBeenCalledWith(Screens.EarnEnterAmount, { tokenId: mockArbUsdcTokenId }) + }) + it('navigates to collect screen', () => { + const { getByText } = render( + + + + ) + + expect(getByText('earnFlow.poolCard.exitPool')).toBeTruthy() + fireEvent.press(getByText('earnFlow.poolCard.exitPool')) + expect(navigate).toHaveBeenCalledWith(Screens.EarnCollectScreen, { + depositTokenId: mockArbUsdcTokenId, + poolTokenId: mockArbEthTokenId, + }) + }) +}) diff --git a/src/earn/PoolCard.tsx b/src/earn/PoolCard.tsx new file mode 100644 index 00000000000..ce04b0ffc0a --- /dev/null +++ b/src/earn/PoolCard.tsx @@ -0,0 +1,194 @@ +import BigNumber from 'bignumber.js' +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, Text, View } from 'react-native' +import Button, { BtnSizes, BtnTypes, TextSizes } from 'src/components/Button' +import TokenDisplay from 'src/components/TokenDisplay' +import TokenIcon from 'src/components/TokenIcon' +import { Pool } from 'src/earn/types' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import { useSelector } from 'src/redux/hooks' +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 { tokensByIdSelector } from 'src/tokens/selectors' +import { TokenBalance } from 'src/tokens/slice' + +export default function PoolCard({ pool, testID = 'PoolCard' }: { pool: Pool; testID?: string }) { + const { tokens, networkId, poolTokenId, depositTokenId } = pool + const { t } = useTranslation() + const allTokens = useSelector((state) => tokensByIdSelector(state, [networkId])) + const tokensInfo = useMemo(() => { + return tokens + .map((tokenId) => allTokens[tokenId]) + .filter((token): token is TokenBalance => !!token) + }, [pool.tokens, allTokens]) + const poolTokenInfo = allTokens[poolTokenId] + const depositTokenInfo = allTokens[depositTokenId] + return ( + + + {tokensInfo.map((token, index) => ( + 0 ? { marginLeft: -8, zIndex: -index } : {}} + /> + ))} + + + {tokensInfo.map((token) => token.symbol).join(' / ')} + + + {t('earnFlow.poolCard.onNetwork', { networkName: NETWORK_NAMES[networkId] })} + + + + + + {t('earnFlow.poolCard.rate')} + + {t('earnFlow.poolCard.apy', { + apy: new BigNumber(pool.apy).multipliedBy(100).toFixed(2), + })} + + + + {t('earnFlow.poolCard.reward')} + {`${new BigNumber(pool.reward).multipliedBy(100).toFixed(2)}%`} + + + {t('earnFlow.poolCard.tvl')} + {`$${new BigNumber(pool.tvl).toFormat()}`} + + + {poolTokenInfo?.balance.gt(0) && !!depositTokenInfo ? ( + + + + {t('earnFlow.poolCard.deposited')} + + {`(`} + + {`) `} + + + + + +