diff --git a/src/assets/svg/ic_claim.svg b/src/assets/svg/ic_claim.svg new file mode 100644 index 0000000000..8348f729eb --- /dev/null +++ b/src/assets/svg/ic_claim.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/pages/Earns/UserPositions/TableContent.tsx b/src/pages/Earns/UserPositions/TableContent.tsx new file mode 100644 index 0000000000..157e9997cb --- /dev/null +++ b/src/pages/Earns/UserPositions/TableContent.tsx @@ -0,0 +1,270 @@ +import { t } from '@lingui/macro' +import { Minus, Plus } from 'react-feather' +import { Link, useNavigate } from 'react-router-dom' +import { useMedia } from 'react-use' +import { Flex, Text } from 'rebass' +import { EarnPosition, PositionStatus } from 'services/zapEarn' + +import { ReactComponent as IconClaim } from 'assets/svg/ic_claim.svg' +import { ReactComponent as IconEarnNotFound } from 'assets/svg/ic_earn_not_found.svg' +import CopyHelper from 'components/Copy' +import { MouseoverTooltipDesktopOnly } from 'components/Tooltip' +import { APP_PATHS } from 'constants/index' +import { useActiveWeb3React } from 'hooks' +import useTheme from 'hooks/useTheme' +import { useWalletModalToggle } from 'state/application/hooks' +import { MEDIA_WIDTHS } from 'theme' +import { shortenAddress } from 'utils' +import { formatDisplayNumber } from 'utils/numbers' + +import { CurrencyRoundedImage, CurrencySecondImage } from '../PoolExplorer/styles' +import { PositionAction as PositionActionBtn } from '../PositionDetail/styles' +import { + Badge, + BadgeType, + ChainImage, + DexImage, + Divider, + EmptyPositionText, + ImageContainer, + PositionAction, + PositionOverview, + PositionRow, + PositionTableBody, + PositionValueLabel, + PositionValueWrapper, +} from './styles' + +export default function TableContent({ + positions, + onOpenZapInWidget, + onOpenZapOut, +}: { + positions: Array | undefined + onOpenZapInWidget: (pool: { exchange: string; chainId?: number; address: string }, positionId?: string) => void + onOpenZapOut: (position: { dex: string; chainId: number; poolAddress: string; id: string }) => void +}) { + const { account } = useActiveWeb3React() + const navigate = useNavigate() + const toggleWalletModal = useWalletModalToggle() + const theme = useTheme() + const upToLarge = useMedia(`(max-width: ${MEDIA_WIDTHS.upToLarge}px)`) + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + + const handleOpenIncreaseLiquidityWidget = (e: React.MouseEvent, position: EarnPosition) => { + e.stopPropagation() + onOpenZapInWidget( + { + exchange: position.pool.project || '', + chainId: position.chainId, + address: position.pool.poolAddress, + }, + position.tokenId, + ) + } + + const handleOpenZapOut = (e: React.MouseEvent, position: EarnPosition) => { + e.stopPropagation() + onOpenZapOut({ + dex: position.pool.project || '', + chainId: position.chainId, + id: position.tokenId, + poolAddress: position.pool.poolAddress, + }) + } + + return ( + + {account && positions && positions.length > 0 ? ( + positions.map(position => { + const { id, status, chainId: poolChainId } = position + const positionId = position.tokenId + const chainImage = position.chainLogo + const dexImage = position.pool.projectLogo + const dexVersion = position.pool.project?.split(' ')?.[1] || '' + const token0Logo = position.pool.tokenAmounts[0]?.token.logo + const token1Logo = position.pool.tokenAmounts[1]?.token.logo + const token0Symbol = position.pool.tokenAmounts[0]?.token.symbol + const token1Symbol = position.pool.tokenAmounts[1]?.token.symbol + const poolFee = position.pool.fees?.[0] + const poolAddress = position.pool.poolAddress + const totalValue = position.currentPositionValue + const token0TotalProvide = + position.currentAmounts[0]?.quotes.usd.value / position.currentAmounts[0]?.quotes.usd.price + const token1TotalProvide = + position.currentAmounts[1]?.quotes.usd.value / position.currentAmounts[1]?.quotes.usd.price + const token0EarnedAmount = + position.feePending[0]?.quotes.usd.value / position.feePending[0]?.quotes.usd.price + + position.feesClaimed[0]?.quotes.usd.value / position.feesClaimed[0]?.quotes.usd.price + const token1EarnedAmount = + position.feePending[1]?.quotes.usd.value / position.feePending[1]?.quotes.usd.price + + position.feesClaimed[1]?.quotes.usd.value / position.feesClaimed[1]?.quotes.usd.price + const token0TotalAmount = token0TotalProvide + token0EarnedAmount + const token1TotalAmount = token1TotalProvide + token1EarnedAmount + const earning7d = position.earning7d + const totalUnclaimedFee = position.feePending.reduce((a, b) => a + b.quotes.usd.value, 0) + const token0UnclaimedAmount = + position.feePending[0]?.quotes.usd.value / position.feePending[0]?.quotes.usd.price + const token1UnclaimedAmount = + position.feePending[1]?.quotes.usd.value / position.feePending[1]?.quotes.usd.price + + return ( + + navigate({ + pathname: APP_PATHS.EARN_POSITION_DETAIL.replace(':chainId', poolChainId.toString()).replace( + ':id', + id, + ), + }) + } + > + + + + + + + + + {token0Symbol}/{token1Symbol} + + {poolFee && {poolFee}%} + + ● {status === PositionStatus.IN_RANGE ? t`In range` : t`Out of range`} + + + + + + + {dexVersion} + + + + #{positionId} + + + {shortenAddress(poolChainId, poolAddress, 4)} + + + + + {upToLarge && !upToSmall && ( + + handleOpenIncreaseLiquidityWidget(e, position)}> + + + handleOpenZapOut(e, position)}> + + + + + + + )} + + {t`Value`} + + + {formatDisplayNumber(token0TotalAmount, { significantDigits: 6 })} {token0Symbol} + + + {formatDisplayNumber(token1TotalAmount, { significantDigits: 6 })} {token1Symbol} + + + } + width="fit-content" + placement="bottom" + > + + {formatDisplayNumber(totalValue, { + style: 'currency', + significantDigits: 4, + })} + + + + + {t`APR`} + + {formatDisplayNumber(earning7d, { + style: 'currency', + significantDigits: 4, + })} + + + + {t`Unclaimed Fee`} + + + {formatDisplayNumber(token0UnclaimedAmount, { significantDigits: 6 })} {token0Symbol} + + + {formatDisplayNumber(token1UnclaimedAmount, { significantDigits: 6 })} {token1Symbol} + + + } + width="fit-content" + placement="bottom" + > + {formatDisplayNumber(totalUnclaimedFee, { style: 'currency', significantDigits: 4 })} + + + + {t`Bal`} + + + {formatDisplayNumber(token0TotalProvide, { significantDigits: 4 })} {token0Symbol} + + {upToSmall && } + + {formatDisplayNumber(token1TotalProvide, { significantDigits: 4 })} {token1Symbol} + + + + + {(upToSmall || !upToLarge) && ( + + + handleOpenIncreaseLiquidityWidget(e, position)}> + + + + + handleOpenZapOut(e, position)}> + + + + + + + + )} + + ) + }) + ) : ( + + + + {t`You don’t have any liquidity positions yet`}. + {t`Explore Liquidity Pools to get started`}! + + {!account && Connect Wallet} + + )} + + ) +} diff --git a/src/pages/Earns/UserPositions/index.tsx b/src/pages/Earns/UserPositions/index.tsx index b9a50df8ea..a93e3564b8 100644 --- a/src/pages/Earns/UserPositions/index.tsx +++ b/src/pages/Earns/UserPositions/index.tsx @@ -1,57 +1,32 @@ import { t } from '@lingui/macro' import { useEffect, useMemo, useRef, useState } from 'react' -import { Minus, Plus } from 'react-feather' -import { Link, useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { useMedia } from 'react-use' import { Flex, Text } from 'rebass' -import { EarnPosition, PositionStatus, useUserPositionsQuery } from 'services/zapEarn' +import { useUserPositionsQuery } from 'services/zapEarn' -import { ReactComponent as IconEarnNotFound } from 'assets/svg/ic_earn_not_found.svg' import { ReactComponent as RocketIcon } from 'assets/svg/rocket.svg' -import CopyHelper from 'components/Copy' import LocalLoader from 'components/LocalLoader' import Pagination from 'components/Pagination' -import { MouseoverTooltipDesktopOnly } from 'components/Tooltip' import { APP_PATHS } from 'constants/index' -import { useActiveWeb3React } from 'hooks' -import useTheme from 'hooks/useTheme' -import { useWalletModalToggle } from 'state/application/hooks' +import SortIcon, { Direction } from 'pages/MarketOverview/SortIcon' import { MEDIA_WIDTHS } from 'theme' -import { shortenAddress } from 'utils' -import { formatDisplayNumber } from 'utils/numbers' -import { CurrencyRoundedImage, CurrencySecondImage, Disclaimer, NavigateButton } from '../PoolExplorer/styles' -import { IconArrowLeft, PositionAction as PositionActionBtn } from '../PositionDetail/styles' +import { ContentWrapper, Disclaimer, NavigateButton } from '../PoolExplorer/styles' +import { IconArrowLeft } from '../PositionDetail/styles' import useLiquidityWidget from '../useLiquidityWidget' import useSupportedDexesAndChains from '../useSupportedDexesAndChains' import Filter from './Filter' import PositionBanner from './PositionBanner' -import { - Badge, - BadgeType, - ChainImage, - DexImage, - Divider, - EmptyPositionText, - ImageContainer, - MyLiquidityWrapper, - PositionAction, - PositionOverview, - PositionPageWrapper, - PositionRow, - PositionValueLabel, - PositionValueWrapper, -} from './styles' -import useFilter from './useFilter' +import TableContent from './TableContent' +import { PositionPageWrapper, PositionTableHeader, PositionTableWrapper } from './styles' +import useFilter, { SortBy } from './useFilter' -const LIMIT = 20 +const POSITIONS_TABLE_LIMIT = 10 const MyPositions = () => { - const theme = useTheme() - const { account } = useActiveWeb3React() - const toggleWalletModal = useWalletModalToggle() const navigate = useNavigate() - const upToLarge = useMedia(`(max-width: ${MEDIA_WIDTHS.upToLarge}px)`) + const upToMedium = useMedia(`(max-width: ${MEDIA_WIDTHS.upToMedium}px)`) const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) const { filters, onFilterChange } = useFilter() const { supportedDexes, supportedChains } = useSupportedDexesAndChains(filters) @@ -61,28 +36,35 @@ const MyPositions = () => { const [loading, setLoading] = useState(false) const [page, setPage] = useState(1) - const { data: userPosition, isFetching } = useUserPositionsQuery(filters, { + const { + data: userPosition, + isFetching, + isError, + } = useUserPositionsQuery(filters, { skip: !filters.addresses, pollingInterval: 15_000, }) - const isShowPagination = (!isFetching || !loading) && userPosition && userPosition.length > LIMIT - const positionsToShow = useMemo(() => { - if (!isShowPagination) return userPosition - return userPosition.slice((page - 1) * LIMIT, page * LIMIT) - }, [isShowPagination, page, userPosition]) - - const onOpenIncreaseLiquidityWidget = (e: React.MouseEvent, position: EarnPosition) => { - e.stopPropagation() - handleOpenZapInWidget( - { - exchange: position.pool.project || '', - chainId: position.chainId, - address: position.pool.poolAddress, - }, - position.tokenId, - ) + if ((!isFetching || !loading) && userPosition && userPosition.length > POSITIONS_TABLE_LIMIT) + return userPosition.slice((page - 1) * POSITIONS_TABLE_LIMIT, page * POSITIONS_TABLE_LIMIT) + + return userPosition + }, [isFetching, loading, page, userPosition]) + + const onSortChange = (sortBy: string) => { + setPage(1) + if (!filters.sortBy || filters.sortBy !== sortBy) { + onFilterChange('sortBy', sortBy) + onFilterChange('orderBy', Direction.DESC) + return + } + if (filters.orderBy === Direction.DESC) { + onFilterChange('orderBy', Direction.ASC) + return + } + onFilterChange('sortBy', SortBy.VALUE) + onFilterChange('orderBy', Direction.DESC) } useEffect(() => { @@ -132,210 +114,62 @@ const MyPositions = () => { }} /> - - {isFetching && loading ? ( - - ) : account && positionsToShow && positionsToShow.length > 0 ? ( - positionsToShow.map(position => { - const { id, status, chainId: poolChainId } = position - const positionId = position.tokenId - const chainImage = position.chainLogo - const dexImage = position.pool.projectLogo - const dexVersion = position.pool.project?.split(' ')?.[1] || '' - const token0Logo = position.pool.tokenAmounts[0]?.token.logo - const token1Logo = position.pool.tokenAmounts[1]?.token.logo - const token0Symbol = position.pool.tokenAmounts[0]?.token.symbol - const token1Symbol = position.pool.tokenAmounts[1]?.token.symbol - const poolFee = position.pool.fees?.[0] - const poolAddress = position.pool.poolAddress - const totalValue = position.currentPositionValue - const token0TotalProvide = - position.currentAmounts[0]?.quotes.usd.value / position.currentAmounts[0]?.quotes.usd.price - const token1TotalProvide = - position.currentAmounts[1]?.quotes.usd.value / position.currentAmounts[1]?.quotes.usd.price - const token0EarnedAmount = - position.feePending[0]?.quotes.usd.value / position.feePending[0]?.quotes.usd.price + - position.feesClaimed[0]?.quotes.usd.value / position.feesClaimed[0]?.quotes.usd.price - const token1EarnedAmount = - position.feePending[1]?.quotes.usd.value / position.feePending[1]?.quotes.usd.price + - position.feesClaimed[1]?.quotes.usd.value / position.feesClaimed[1]?.quotes.usd.price - const token0TotalAmount = token0TotalProvide + token0EarnedAmount - const token1TotalAmount = token1TotalProvide + token1EarnedAmount - const totalEarnedFee = - position.feePending.reduce((a, b) => a + b.quotes.usd.value, 0) + - position.feesClaimed.reduce((a, b) => a + b.quotes.usd.value, 0) - - return ( - - navigate({ - pathname: APP_PATHS.EARN_POSITION_DETAIL.replace(':chainId', poolChainId.toString()).replace( - ':id', - id, - ), - }) - } + + + {!upToMedium && positionsToShow && positionsToShow.length > 0 && ( + + {t`Position`} + onSortChange(SortBy.VALUE)} + > + {t`Value`} + + + onSortChange(SortBy.APR_7D)} > - - - - - - - - - {token0Symbol}/{token1Symbol} - - {poolFee && {poolFee}%} - - ● {status === PositionStatus.IN_RANGE ? t`In range` : t`Out of range`} - - - - - - - {dexVersion} - - - - #{positionId} - - - {shortenAddress(poolChainId, poolAddress, 4)} - - - - - {upToLarge && !upToSmall && ( - - { - e.stopPropagation() - handleOpenZapOut({ - dex: position.pool.project || '', - chainId: position.chainId, - id: position.tokenId, - poolAddress: position.pool.poolAddress, - }) - }} - > - - - onOpenIncreaseLiquidityWidget(e, position)}> - - - - )} - - {t`Value`} - - - {formatDisplayNumber(token0TotalAmount, { significantDigits: 6 })} {token0Symbol} - - - {formatDisplayNumber(token1TotalAmount, { significantDigits: 6 })} {token1Symbol} - - - } - width="fit-content" - placement="bottom" - > - - {formatDisplayNumber(totalValue, { - style: 'currency', - significantDigits: 4, - })} - - - - - {t`Earned Fee`} - - - {formatDisplayNumber(token0EarnedAmount, { significantDigits: 6 })} {token0Symbol} - - - {formatDisplayNumber(token1EarnedAmount, { significantDigits: 6 })} {token1Symbol} - - - } - width="fit-content" - placement="bottom" - > - {formatDisplayNumber(totalEarnedFee, { style: 'currency', significantDigits: 4 })} - - - - Balance - - - {formatDisplayNumber(token0TotalProvide, { significantDigits: 4 })} {token0Symbol} - - {upToSmall && } - - {formatDisplayNumber(token1TotalProvide, { significantDigits: 4 })} {token1Symbol} - - - - {(upToSmall || !upToLarge) && ( - - - { - e.stopPropagation() - handleOpenZapOut({ - dex: position.pool.project || '', - chainId: position.chainId, - id: position.tokenId, - poolAddress: position.pool.poolAddress, - }) - }} - > - - - - - onOpenIncreaseLiquidityWidget(e, position)}> - - - - - )} - - ) - }) - ) : ( - - - - {t`You don’t have any liquidity positions yet`}. - {t`Explore Liquidity Pools to get started`}! - - {!account && Connect Wallet} - + {t`7D APR`} + + + onSortChange(SortBy.UNCLAIMED_FEE)} + > + {t`Unclaimed fee`} + + + {t`Balance`} + {t`Price Range`} + {t`Actions`} + + )} + {isFetching && loading ? ( + + ) : ( + + )} + + {!isError && ( + setPage(newPage)} + totalCount={userPosition?.length || 0} + currentPage={page || 1} + pageSize={POSITIONS_TABLE_LIMIT} + /> )} - - {isShowPagination && ( - setPage(newPage)} - totalCount={userPosition.length} - currentPage={page} - pageSize={LIMIT} - /> - )} + {t`KyberSwap provides tools for tracking & adding liquidity to third-party Protocols. For any pool-related concerns, please contact the respective Liquidity Protocol directly.`} diff --git a/src/pages/Earns/UserPositions/styles.tsx b/src/pages/Earns/UserPositions/styles.tsx index dbc5abde72..e8ea9420ec 100644 --- a/src/pages/Earns/UserPositions/styles.tsx +++ b/src/pages/Earns/UserPositions/styles.tsx @@ -3,10 +3,10 @@ import styled from 'styled-components' import earnLargeBg from 'assets/banners/earn_background_large.png' -import { PoolPageWrapper } from '../PoolExplorer/styles' +import { PoolPageWrapper, TableBody, TableHeader, TableWrapper } from '../PoolExplorer/styles' export const PositionPageWrapper = styled(PoolPageWrapper)` - padding: 24px 6rem 50px; + padding: 24px 6rem 62px; ${({ theme }) => theme.mediaWidth.upToLarge` padding: 24px 6rem 60px; @@ -17,28 +17,16 @@ export const PositionPageWrapper = styled(PoolPageWrapper)` `} ` -export const MyLiquidityWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 1rem; - - ${({ theme }) => theme.mediaWidth.upToSmall` - max-height: unset; - `} -` - export const PositionRow = styled.div` display: grid; - grid-template-columns: 2fr 1fr 1fr 1fr 75px; + grid-template-columns: 3fr 1fr 1fr 1.2fr 1.2fr 1.5fr 1fr; grid-template-rows: 1fr; - background-color: ${({ theme }) => rgba(theme.background, 0.8)}; - border-radius: 20px; padding: 16px 28px; row-gap: 8px; ${({ theme }) => theme.mediaWidth.upToLarge` justify-content: flex-start; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); grid-template-rows: 1fr 1fr; `} @@ -47,11 +35,18 @@ export const PositionRow = styled.div` flex-direction: column; row-gap: 16px; padding: 16px; + border-radius: 20px; + background: ${rgba(theme.background, 0.8)}; + margin-bottom: 16px; `} + &:last-child { + margin-bottom: 0; + } + &:hover { cursor: pointer; - filter: brightness(1.1); + background: #31cb9e1a; } ` @@ -61,7 +56,7 @@ export const PositionOverview = styled.div` gap: 8px; ${({ theme }) => theme.mediaWidth.upToLarge` - grid-column: span 2; + grid-column: span 3; `} ` @@ -147,6 +142,11 @@ export const PositionValueLabel = styled.p` color: ${({ theme }) => theme.subText}; position: relative; top: 1px; + display: none; + + ${({ theme }) => theme.mediaWidth.upToLarge` + display: block; + `} ${({ theme }) => theme.mediaWidth.upToSmall` font-size: 16px; @@ -284,3 +284,18 @@ export const BannerDataItem = styled.div` justify-content: space-between; `} ` + +export const PositionTableHeader = styled(TableHeader)` + grid-template-columns: 3fr 1fr 1fr 1.2fr 1.2fr 1.5fr 1fr; +` + +export const PositionTableWrapper = styled(TableWrapper)` + ${({ theme }) => theme.mediaWidth.upToSmall` + background: transparent; + margin: 0; + `} +` + +export const PositionTableBody = styled(TableBody)` + max-height: unset; +` diff --git a/src/pages/Earns/UserPositions/useFilter.ts b/src/pages/Earns/UserPositions/useFilter.ts index 3763828ba4..24f34abc6e 100644 --- a/src/pages/Earns/UserPositions/useFilter.ts +++ b/src/pages/Earns/UserPositions/useFilter.ts @@ -1,6 +1,13 @@ import { useEffect, useState } from 'react' import { useActiveWeb3React } from 'hooks' +import { Direction } from 'pages/MarketOverview/SortIcon' + +export enum SortBy { + VALUE = 'value', + APR_7D = 'apr_7d', + UNCLAIMED_FEE = 'unclaimed_fee', +} export default function useFilter() { const { account } = useActiveWeb3React() @@ -10,6 +17,8 @@ export default function useFilter() { protocols: '', status: '', q: '', + sortBy: SortBy.VALUE, + orderBy: Direction.DESC, }) const onFilterChange = (key: string, value: string | number) => { diff --git a/src/services/zapEarn.ts b/src/services/zapEarn.ts index 42652ddb23..3acb755cfe 100644 --- a/src/services/zapEarn.ts +++ b/src/services/zapEarn.ts @@ -1,6 +1,9 @@ import { ChainId } from '@kyberswap/ks-sdk-core' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { SortBy } from 'pages/Earns/UserPositions/useFilter' +import { Direction } from 'pages/MarketOverview/SortIcon' + interface ExplorerLandingResponse { data: { highlightedPools: Array @@ -108,6 +111,8 @@ export interface PositionQueryParams { protocols?: string status?: string q?: string + sortBy?: string + orderBy?: string } export interface EarnPosition { @@ -271,8 +276,33 @@ const zapEarnServiceApi = createApi({ _meta, arg, ) => { - if (!arg.status) return response.data.positions - return response.data.positions.filter((position: EarnPosition) => position.status === arg.status) + let positions + if (!arg.status) positions = response.data.positions + else positions = response.data.positions.filter((position: EarnPosition) => position.status === arg.status) + + if (arg.sortBy && positions && positions.length) { + if (arg.sortBy === SortBy.VALUE) { + positions = positions.sort((a, b) => { + const aValue = a.currentPositionValue + const bValue = b.currentPositionValue + return arg.orderBy === Direction.ASC ? aValue - bValue : bValue - aValue + }) + } else if (arg.sortBy === SortBy.APR_7D) { + positions = positions.sort((a, b) => { + const aValue = a.earning7d + const bValue = b.earning7d + return arg.orderBy === Direction.ASC ? aValue - bValue : bValue - aValue + }) + } else if (arg.sortBy === SortBy.UNCLAIMED_FEE) { + positions = positions.sort((a, b) => { + const aValue = a.feePending.reduce((total, fee) => total + fee.quotes.usd.value, 0) + const bValue = b.feePending.reduce((total, fee) => total + fee.quotes.usd.value, 0) + return arg.orderBy === Direction.ASC ? aValue - bValue : bValue - aValue + }) + } + } + + return positions }, }), addFavorite: builder.mutation({