From e19e35e1b5c8598ebf79c18726feda71cc7e447d Mon Sep 17 00:00:00 2001 From: Tien Nguyen Date: Thu, 15 Aug 2024 11:01:04 +0700 Subject: [PATCH] Feat: Limit Orders Book (#2513) * feat: limit orders book * Fix: node version * fix: add groupBy to object type * fix significant digits show for mobile and adjust column space * Add refresh loading UI * fix some ui * improve large orders display and loading * fix issue refresh loading animation infinite * fix some styles * fix bug orders arrange failed, due to js object change the property order * add 'RATE' text to table header * fix inline-style for TabButton * fix orders sort logic * fix orders sort logic * remove unused code --------- Co-authored-by: Tien Nguyen --- .github/workflows/ci.yaml | 4 +- .github/workflows/pr.yaml | 4 +- .github/workflows/release.yaml | 2 +- .github/workflows/schedule.yml | 2 +- .github/workflows/storybook.yaml | 2 +- .nvmrc | 2 +- package.json | 1 - src/components/TabButton.tsx | 20 +- .../ListLimitOrder/RefreshLoading.tsx | 136 ++++++++ .../LimitOrder/ListLimitOrder/TabSelector.tsx | 52 +++ .../LimitOrder/ListLimitOrder/index.tsx | 38 +++ .../LimitOrder/ListOrder/TabSelector.tsx | 53 ++-- .../swapv2/LimitOrder/ListOrder/index.tsx | 35 +- .../swapv2/LimitOrder/OrderBook/OrderItem.tsx | 71 +++++ .../LimitOrder/OrderBook/TableHeader.tsx | 51 +++ .../swapv2/LimitOrder/OrderBook/index.tsx | 300 ++++++++++++++++++ src/components/swapv2/LimitOrder/type.ts | 44 +++ src/constants/index.ts | 1 + src/hooks/useBaseTradeInfo.ts | 4 +- src/pages/PartnerSwap/index.tsx | 2 +- src/pages/SwapV3/index.tsx | 3 +- src/services/limitOrder.ts | 26 +- src/types/object.d.ts | 19 ++ 23 files changed, 812 insertions(+), 60 deletions(-) create mode 100644 src/components/swapv2/LimitOrder/ListLimitOrder/RefreshLoading.tsx create mode 100644 src/components/swapv2/LimitOrder/ListLimitOrder/TabSelector.tsx create mode 100644 src/components/swapv2/LimitOrder/ListLimitOrder/index.tsx create mode 100644 src/components/swapv2/LimitOrder/OrderBook/OrderItem.tsx create mode 100644 src/components/swapv2/LimitOrder/OrderBook/TableHeader.tsx create mode 100644 src/components/swapv2/LimitOrder/OrderBook/index.tsx diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26e8d1ab17..a7e6edfd2e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -74,7 +74,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 20.9.0 + node-version: 21.0.0 registry-url: 'https://npm.pkg.github.com' scope: '@kybernetwork' @@ -150,7 +150,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 20.9.0 + node-version: 21.0.0 registry-url: 'https://npm.pkg.github.com' scope: '@kybernetwork' diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 6813dab501..ac4c5a0d68 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -76,7 +76,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 20.9.0 + node-version: 21.0.0 registry-url: 'https://npm.pkg.github.com' scope: '@kybernetwork' token: ${{ secrets.GH_PAT }} @@ -153,7 +153,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 20.9.0 + node-version: 21.0.0 registry-url: 'https://npm.pkg.github.com' scope: '@kybernetwork' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b5616604a6..b7a39ef658 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -41,7 +41,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 20.9.0 + node-version: 21.0.0 registry-url: 'https://npm.pkg.github.com' scope: '@kybernetwork' diff --git a/.github/workflows/schedule.yml b/.github/workflows/schedule.yml index 0cb4af026e..d41763e6ad 100644 --- a/.github/workflows/schedule.yml +++ b/.github/workflows/schedule.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 20.9.0 + node-version: 21.0.0 registry-url: 'https://npm.pkg.github.com' scope: '@kybernetwork' diff --git a/.github/workflows/storybook.yaml b/.github/workflows/storybook.yaml index b9c31416c4..a82d6530ad 100644 --- a/.github/workflows/storybook.yaml +++ b/.github/workflows/storybook.yaml @@ -74,7 +74,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 20.9.0 + node-version: 21.0.0 registry-url: 'https://npm.pkg.github.com' scope: '@kybernetwork' diff --git a/.nvmrc b/.nvmrc index c946e1df49..4e479ce8d0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.9.0 \ No newline at end of file +v21.0.0 \ No newline at end of file diff --git a/package.json b/package.json index 16e2750cec..5f0e0218b6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "homepage": "/", "license": "GPL-3.0-or-later", "engines": { - "node": "~20.9.0", "yarn": ">=1.22" }, "packageManager": "yarn@1.22.19", diff --git a/src/components/TabButton.tsx b/src/components/TabButton.tsx index 4dd003618f..d29a806d95 100644 --- a/src/components/TabButton.tsx +++ b/src/components/TabButton.tsx @@ -43,13 +43,27 @@ const ButtonWrapper = styled.div<{ active?: boolean; separator?: boolean }>` } ` -type Props = { text?: string; active?: boolean; onClick?: () => void; style?: CSSProperties; separator?: boolean } +type Props = { + text?: string + active?: boolean + onClick?: () => void + style?: CSSProperties + separator?: boolean + className?: string +} const TabButton = forwardRef(function TabButton( - { text, active, onClick, style, separator }, + { text, active, onClick, style, separator, className }, ref, ) { return ( - + {text} ) diff --git a/src/components/swapv2/LimitOrder/ListLimitOrder/RefreshLoading.tsx b/src/components/swapv2/LimitOrder/ListLimitOrder/RefreshLoading.tsx new file mode 100644 index 0000000000..d3c1d93c1d --- /dev/null +++ b/src/components/swapv2/LimitOrder/ListLimitOrder/RefreshLoading.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react' +import styled, { css, keyframes } from 'styled-components' + +import useDebounce from 'hooks/useDebounce' +import useTheme from 'hooks/useTheme' + +const INTERVAL_REFETCH_TIME = 10 // seconds + +const spin = keyframes` + from { + transform:rotate(0deg); + } + to { + transform:rotate(360deg); + } +` + +const WrappedSvg = styled.svg<{ spinning: boolean }>` + ${({ spinning }) => + spinning + ? css` + animation-name: ${spin}; + animation-duration: 696ms; + animation-iteration-count: infinite; + animation-timing-function: linear; + ` + : ''} +` + +let interval: NodeJS.Timeout + +const Spin = ({ countdown }: { countdown: number }) => { + const theme = useTheme() + + return ( + + + + + + + + + + + + + ) +} + +const SpinWrapper = styled.div` + display: flex; + align-items: center; + position: relative; + width: fit-content; +` + +const CountDown = styled.div` + font-size: 0.75rem; + font-weight: 500; + position: absolute; + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + text-align: center; + color: ${({ theme }) => theme.primary}; +` + +export default function RefreshLoading({ + refetchLoading, + onRefresh, +}: { + refetchLoading: boolean + onRefresh: () => void +}) { + const [countdown, setCountdown] = useState(0) + + const debouncedRefetchLoading = useDebounce(refetchLoading, 100) + + useEffect(() => { + if (!refetchLoading && !debouncedRefetchLoading) setCountdown(INTERVAL_REFETCH_TIME * 1_000) + else if (refetchLoading && debouncedRefetchLoading) setCountdown(0) + }, [refetchLoading, debouncedRefetchLoading]) + + useEffect(() => { + if (countdown > 0) { + interval = setInterval(() => { + const newCountdown = countdown - 10 + setCountdown(newCountdown) + if (newCountdown === 10) { + onRefresh() + } + }, 10) + } + + return () => { + clearInterval(interval) + } + }, [countdown, onRefresh]) + + return ( + + + + {countdown > 0 && {(countdown / 1_000).toFixed()}} + + ) +} diff --git a/src/components/swapv2/LimitOrder/ListLimitOrder/TabSelector.tsx b/src/components/swapv2/LimitOrder/ListLimitOrder/TabSelector.tsx new file mode 100644 index 0000000000..52065ac122 --- /dev/null +++ b/src/components/swapv2/LimitOrder/ListLimitOrder/TabSelector.tsx @@ -0,0 +1,52 @@ +import { t } from '@lingui/macro' +import styled from 'styled-components' + +import TabButton from 'components/TabButton' + +import { LimitOrderTab } from '../type' + +const TabSelectorWrapper = styled.div` + display: flex; + overflow: hidden; + border-top-left-radius: 19px; + width: fit-content; + + ${({ theme }) => theme.mediaWidth.upToSmall` + border-top-left-radius: 0; + width: 100%; + `}; +` + +const StyledTabButton = styled(TabButton)` + padding: 16px; + flex: unset; + font-size: 14px; + width: fit-content; + + ${({ theme }) => theme.mediaWidth.upToSmall` + width: 50%; + `}; +` + +export default function TabSelector({ + activeTab, + setActiveTab, +}: { + activeTab: LimitOrderTab + setActiveTab: (n: LimitOrderTab) => void +}) { + return ( + + setActiveTab(LimitOrderTab.ORDER_BOOK)} + text={t`Open Limit Orders`} + /> + setActiveTab(LimitOrderTab.MY_ORDER)} + /> + + ) +} diff --git a/src/components/swapv2/LimitOrder/ListLimitOrder/index.tsx b/src/components/swapv2/LimitOrder/ListLimitOrder/index.tsx new file mode 100644 index 0000000000..8cccc90d91 --- /dev/null +++ b/src/components/swapv2/LimitOrder/ListLimitOrder/index.tsx @@ -0,0 +1,38 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { useState } from 'react' +import { Flex } from 'rebass' +import styled from 'styled-components' + +import ListMyOrder from '../ListOrder' +import OrderBook from '../OrderBook' +import { LimitOrderTab } from '../type' +import TabSelector from './TabSelector' + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + border-radius: 20px; + border: 1px solid ${({ theme }) => theme.border}; + ${({ theme }) => theme.mediaWidth.upToSmall` + margin-left: -16px; + width: 100vw; + border-left: none; + border-right: none; + border-radius: 0; + border: none; + `}; +` + +export default function ListLimitOrder({ customChainId }: { customChainId?: ChainId }) { + const [activeTab, setActiveTab] = useState(LimitOrderTab.ORDER_BOOK) + + return ( + + + + + + {activeTab === LimitOrderTab.ORDER_BOOK ? : } + + ) +} diff --git a/src/components/swapv2/LimitOrder/ListOrder/TabSelector.tsx b/src/components/swapv2/LimitOrder/ListOrder/TabSelector.tsx index d3027fb9d5..c7b37d7021 100644 --- a/src/components/swapv2/LimitOrder/ListOrder/TabSelector.tsx +++ b/src/components/swapv2/LimitOrder/ListOrder/TabSelector.tsx @@ -1,38 +1,49 @@ import { t } from '@lingui/macro' import { Flex } from 'rebass' +import styled, { css } from 'styled-components' -import TabButton from 'components/TabButton' +import useTheme from 'hooks/useTheme' import { LimitOrderStatus } from '../type' +const TabButton = styled.div<{ active: boolean }>` + font-size: 14px; + line-height: 20px; + transition: all 0.2s ease; + cursor: pointer; + ${({ theme, active }) => + active + ? css` + color: ${theme.primary}; + ` + : css` + color: ${theme.subText}; + :hover { + filter: brightness(1.2); + } + `} +` + const TabSelector = ({ - className, activeTab, setActiveTab, }: { - className?: string activeTab: LimitOrderStatus setActiveTab: (n: LimitOrderStatus) => void }) => { - const style = { padding: '16px', flex: 'unset', fontSize: '14px' } + const theme = useTheme() + return ( - - { - setActiveTab(LimitOrderStatus.ACTIVE) - }} - text={t`Active Orders`} - /> - { - setActiveTab(LimitOrderStatus.CLOSED) - }} - /> + + setActiveTab(LimitOrderStatus.ACTIVE)}> + {t`Active Orders`} + + + | + + setActiveTab(LimitOrderStatus.CLOSED)}> + {t`Order History`} + ) } diff --git a/src/components/swapv2/LimitOrder/ListOrder/index.tsx b/src/components/swapv2/LimitOrder/ListOrder/index.tsx index 68ee8f4c4c..e20c3fe811 100644 --- a/src/components/swapv2/LimitOrder/ListOrder/index.tsx +++ b/src/components/swapv2/LimitOrder/ListOrder/index.tsx @@ -6,7 +6,7 @@ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { Trash } from 'react-feather' import { useNavigate } from 'react-router-dom' import { useMedia } from 'react-use' -import { Flex, Text } from 'rebass' +import { Text } from 'rebass' import { useGetListOrdersQuery } from 'services/limitOrder' import styled from 'styled-components' @@ -18,7 +18,6 @@ import Pagination from 'components/Pagination' import Row from 'components/Row' import SearchInput from 'components/SearchInput' import Select from 'components/Select' -import SubscribeNotificationButton from 'components/SubscribeButton' import useRequestCancelOrder from 'components/swapv2/LimitOrder/ListOrder/useRequestCancelOrder' import { APP_PATHS, EMPTY_ARRAY, RTK_QUERY_TAGS, TRANSACTION_STATE_DEFAULT } from 'constants/index' import { useActiveWeb3React } from 'hooks' @@ -49,17 +48,20 @@ import TableHeader from './TableHeader' const Wrapper = styled.div` display: flex; flex-direction: column; - border-radius: 20px; gap: 1rem; - border: 1px solid ${({ theme }) => theme.border}; ${({ theme }) => theme.mediaWidth.upToSmall` - margin-left: -16px; width: 100vw; - border-left: none; - border-right: none; `}; ` +const TabSelectorWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.background}; + border-top: 1px solid ${({ theme }) => theme.background}; +` + const ButtonCancelAll = styled(ButtonLight)` font-size: 14px; width: fit-content; @@ -71,7 +73,7 @@ const ButtonCancelAll = styled(ButtonLight)` ` const PAGE_SIZE = 10 -const NoResultWrapper = styled.div` +export const NoResultWrapper = styled.div` min-height: 140px; display: flex; flex-direction: column; @@ -131,7 +133,7 @@ const SearchInputWrapped = styled(SearchInput)` `}; ` -export default function ListLimitOrder({ customChainId }: { customChainId?: ChainId }) { +export default function ListMyOrder({ customChainId }: { customChainId?: ChainId }) { const { account, chainId: walletChainId, networkInfo } = useActiveWeb3React() const chainId = customChainId || walletChainId const [curPage, setCurPage] = useState(1) @@ -172,6 +174,7 @@ export default function ListLimitOrder({ customChainId }: { customChainId?: Chai }, [orders]) const { refetch, data: tokenPrices } = useTokenPricesWithLoading(tokenAddresses, chainId) + useEffect(() => { // Refresh token prices each 10 seconds const interval = setInterval(refetch, 10_000) @@ -298,14 +301,6 @@ export default function ListLimitOrder({ customChainId }: { customChainId?: Chai }, [totalOrderNotCancelling, orders, ordersUpdating]) const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) - const subscribeBtn = !isPartnerSwap && ( - - ) const theme = useTheme() @@ -315,17 +310,15 @@ export default function ListLimitOrder({ customChainId }: { customChainId?: Chai return ( - + - {!upToSmall && subscribeBtn} - + - {upToSmall && subscribeBtn} theme.mediaWidth.upToSmall` + grid-template-columns: 1.6fr 2fr 2fr 1fr; + `} +` + +const Rate = styled.div<{ reverse?: boolean }>` + color: ${({ theme, reverse }) => (reverse ? theme.primary : theme.red)}; +` + +const AmountInfo = ({ + plus, + amount, + currency, + upToSmall, +}: { + plus?: boolean + amount: string + currency?: Currency + upToSmall?: boolean +}) => ( + + + {plus ? '+' : '-'} + {amount} + +) + +export default function OrderItem({ + reverse, + order, + style, +}: { + reverse?: boolean + order: LimitOrderFromTokenPairFormatted + style: CSSProperties +}) { + const theme = useTheme() + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const { currencyIn, currencyOut } = useLimitState() + + return ( + + {order.rate} + + + + {!upToSmall && 'Filled '} + {order.filled}% + + + ) +} diff --git a/src/components/swapv2/LimitOrder/OrderBook/TableHeader.tsx b/src/components/swapv2/LimitOrder/OrderBook/TableHeader.tsx new file mode 100644 index 0000000000..61eb2bc7c9 --- /dev/null +++ b/src/components/swapv2/LimitOrder/OrderBook/TableHeader.tsx @@ -0,0 +1,51 @@ +import { Trans } from '@lingui/macro' +import { rgba } from 'polished' +import { useMedia } from 'react-use' +import { Text } from 'rebass' +import styled from 'styled-components' + +import { useLimitState } from 'state/limit/hooks' +import { MEDIA_WIDTHS } from 'theme' + +import { ItemWrapper } from './OrderItem' + +const Header = styled(ItemWrapper)` + background: ${({ theme }) => rgba(theme.white, 0.04)}; + color: ${({ theme }) => theme.subText}; + font-size: 12px; + line-height: 16px; + font-weight: 500; + padding: 16px 12px; + cursor: default; + :hover { + background-color: ${({ theme }) => rgba(theme.primary, 0.2)}; + } +` + +export default function TableHeader() { + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const { currencyIn, currencyOut } = useLimitState() + + return ( +
+ + RATE + {upToSmall ?
: ' '}({currencyIn?.symbol || ''}/ + {currencyOut?.symbol || ''}) +
+ + AMOUNT + {upToSmall ?
: ' '} + ({currencyIn?.symbol}) +
+ + AMOUNT + {upToSmall ?
: ' '} + ({currencyOut?.symbol}) +
+ + ORDER STATUS + +
+ ) +} diff --git a/src/components/swapv2/LimitOrder/OrderBook/index.tsx b/src/components/swapv2/LimitOrder/OrderBook/index.tsx new file mode 100644 index 0000000000..e422f7b0d0 --- /dev/null +++ b/src/components/swapv2/LimitOrder/OrderBook/index.tsx @@ -0,0 +1,300 @@ +import { Currency, CurrencyAmount } from '@kyberswap/ks-sdk-core' +import { Trans } from '@lingui/macro' +import { rgba } from 'polished' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useMedia } from 'react-use' +import { FixedSizeList } from 'react-window' +import { Text } from 'rebass' +import { useGetOrdersByTokenPairQuery } from 'services/limitOrder' +import styled, { CSSProperties } from 'styled-components' + +import { ReactComponent as NoDataIcon } from 'assets/svg/no-data.svg' +import LocalLoader from 'components/LocalLoader' +import { useActiveWeb3React } from 'hooks' +import { useBaseTradeInfoLimitOrder } from 'hooks/useBaseTradeInfo' +import useShowLoadingAtLeastTime from 'hooks/useShowLoadingAtLeastTime' +import useTheme from 'hooks/useTheme' +import { useLimitState } from 'state/limit/hooks' +import { MEDIA_WIDTHS } from 'theme' +import { formatDisplayNumber } from 'utils/numbers' + +import RefreshLoading from '../ListLimitOrder/RefreshLoading' +import { NoResultWrapper } from '../ListOrder' +import { LimitOrderFromTokenPair, LimitOrderFromTokenPairFormatted } from '../type' +import OrderItem from './OrderItem' +import TableHeader from './TableHeader' + +const ITEMS_DISPLAY = 10 +const ITEM_HEIGHT = 44 +const DESKTOP_SIGNIFICANT_DIGITS = 6 +const MOBILE_SIGNIFICANT_DIGITS = 5 + +const OrderBookWrapper = styled.div` + display: flex; + flex-direction: column; + margin-top: 1rem; + position: relative; +` + +const RefreshText = styled.div` + display: flex; + align-items: center; + position: absolute; + right: 16px; + top: -2.5rem; + + ${({ theme }) => theme.mediaWidth.upToSmall` + position: static; + margin-bottom: 1rem; + margin-left: 1rem; + `} +` + +const MarketPrice = styled.div` + padding: 8px 12px; + font-size: 20px; + line-height: 24px; + background: ${({ theme }) => rgba(theme.white, 0.04)}; +` + +const OrderItemWrapper = styled(FixedSizeList)` + ::-webkit-scrollbar { + display: unset; + width: 4px; + border-radius: 999px; + } + + /* Track */ + ::-webkit-scrollbar-track { + background: transparent; + border-radius: 999px; + } + + /* Handle */ + ::-webkit-scrollbar-thumb { + background: ${({ theme }) => theme.disableText}; + border-radius: 999px; + } +` + +const NoDataPanel = () => ( + + + + No orders. + + +) + +const formatOrders = ( + orders: LimitOrderFromTokenPair[], + reverse: boolean, + currencyIn: Currency | undefined, + currencyOut: Currency | undefined, + significantDigits: number, +): LimitOrderFromTokenPairFormatted[] => { + if (!currencyIn || !currencyOut) return [] + + // Format orders, remove orders that are above 99% filled and sort descending by rate + const ordersFormatted = orders + .map(order => { + const currencyInAmount = CurrencyAmount.fromRawAmount(!reverse ? currencyIn : currencyOut, order.makingAmount) + const currencyOutAmount = CurrencyAmount.fromRawAmount(!reverse ? currencyOut : currencyIn, order.takingAmount) + const rate = !reverse + ? parseFloat(currencyOutAmount.toExact()) / parseFloat(currencyInAmount.toExact()) + : parseFloat(currencyInAmount.toExact()) / parseFloat(currencyOutAmount.toExact()) + + const firstAmount = (!reverse ? currencyInAmount : currencyOutAmount).toExact() + const secondAmount = (!reverse ? currencyOutAmount : currencyInAmount).toExact() + + const filledMakingAmount = CurrencyAmount.fromRawAmount( + !reverse ? currencyIn : currencyOut, + order.filledMakingAmount, + ) + const filled = (parseFloat(filledMakingAmount.toExact()) / parseFloat(currencyInAmount.toExact())) * 100 + + return { + id: order.id, + rate, + firstAmount, + secondAmount, + filled: filled > 99 ? '100' : filled.toFixed(), + } + }) + .filter(order => order.filled !== '100') + .sort((a, b) => b.rate - a.rate) + .map(order => ({ + ...order, + rate: formatDisplayNumber(order.rate, { significantDigits }), + })) + + // Merge orders with the same rate + const mergedOrders: LimitOrderFromTokenPairFormatted[] = [] + const groupOrders = Map.groupBy(ordersFormatted, ({ rate }: LimitOrderFromTokenPairFormatted) => rate) + + groupOrders.forEach((group: LimitOrderFromTokenPairFormatted[]) => { + const mergedOrder = group?.reduce( + (accumulatorOrder: LimitOrderFromTokenPairFormatted | null, currentOrder: LimitOrderFromTokenPairFormatted) => + accumulatorOrder + ? { + ...currentOrder, + firstAmount: (parseFloat(currentOrder.firstAmount) + parseFloat(accumulatorOrder.firstAmount)).toString(), + secondAmount: ( + parseFloat(currentOrder.secondAmount) + parseFloat(accumulatorOrder.secondAmount) + ).toString(), + } + : currentOrder, + null, + ) + if (mergedOrder) { + mergedOrder.firstAmount = formatDisplayNumber(mergedOrder.firstAmount, { significantDigits }) + mergedOrder.secondAmount = formatDisplayNumber(mergedOrder.secondAmount, { significantDigits }) + mergedOrders.push(mergedOrder) + } + }) + + return mergedOrders +} + +export default function OrderBook() { + const theme = useTheme() + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + + const { chainId } = useActiveWeb3React() + const { currencyIn, currencyOut } = useLimitState() + const { + loading: loadingMarketRate, + tradeInfo: { marketRate = 0 } = {}, + refetch: refetchMarketRate, + } = useBaseTradeInfoLimitOrder(currencyIn, currencyOut, chainId) + + const ordersWrapperRef = useRef>(null) + + const { + data: { orders = [] } = {}, + isLoading: isLoadingOrders, + refetch: refetchOrders, + isFetching: isFetchingOrders, + } = useGetOrdersByTokenPairQuery({ + chainId, + makerAsset: currencyIn?.wrapped?.address, + takerAsset: currencyOut?.wrapped?.address, + }) + const { + data: { orders: reversedOrders = [] } = {}, + isLoading: isLoadingReversedOrder, + refetch: refetchReversedOrder, + isFetching: isFetchingReversedOrder, + } = useGetOrdersByTokenPairQuery({ + chainId, + makerAsset: currencyOut?.wrapped?.address, + takerAsset: currencyIn?.wrapped?.address, + }) + + const loadingOrders = useShowLoadingAtLeastTime(isLoadingOrders) + const loadingReversedOrders = useShowLoadingAtLeastTime(isLoadingReversedOrder) + + const formattedOrders = useMemo( + () => + formatOrders( + orders, + false, + currencyIn, + currencyOut, + upToSmall ? MOBILE_SIGNIFICANT_DIGITS : DESKTOP_SIGNIFICANT_DIGITS, + ), + [orders, currencyIn, currencyOut, upToSmall], + ) + const formattedReversedOrders = useMemo( + () => + formatOrders( + reversedOrders, + true, + currencyIn, + currencyOut, + upToSmall ? MOBILE_SIGNIFICANT_DIGITS : DESKTOP_SIGNIFICANT_DIGITS, + ), + [reversedOrders, currencyIn, currencyOut, upToSmall], + ) + + const refetchLoading = useMemo( + () => loadingMarketRate || isFetchingOrders || isFetchingReversedOrder, + [loadingMarketRate, isFetchingOrders, isFetchingReversedOrder], + ) + + const onRefreshOrders = useCallback(() => { + refetchMarketRate() + refetchOrders() + refetchReversedOrder() + }, [refetchMarketRate, refetchOrders, refetchReversedOrder]) + + // Scroll to bottom when new orders are fetched + useEffect(() => { + if (formattedOrders.length && ordersWrapperRef.current) { + ordersWrapperRef.current.scrollToItem(formattedOrders.length - 1) + } + }, [formattedOrders, loadingOrders, loadingReversedOrders]) + + return ( + + {loadingOrders || loadingReversedOrders ? ( + + ) : ( + <> + + + Orders refresh in + {' '} + + + + + + {formattedOrders.length > 0 ? ( + + {({ index, style }: { index: number; style: CSSProperties }) => { + const order = formattedOrders[index] + return + }} + + ) : ( + + )} + + {marketRate && ( + + {formatDisplayNumber(marketRate, { + significantDigits: upToSmall ? MOBILE_SIGNIFICANT_DIGITS : DESKTOP_SIGNIFICANT_DIGITS, + })} + + )} + + {formattedReversedOrders.length > 0 ? ( + + {({ index, style }: { index: number; style: CSSProperties }) => { + const order = formattedReversedOrders[index] + return + }} + + ) : ( + + )} + + )} + + ) +} diff --git a/src/components/swapv2/LimitOrder/type.ts b/src/components/swapv2/LimitOrder/type.ts index 4a86bff0e2..c7a2e1a026 100644 --- a/src/components/swapv2/LimitOrder/type.ts +++ b/src/components/swapv2/LimitOrder/type.ts @@ -1,5 +1,10 @@ import { ChainId, Currency, Fraction } from '@kyberswap/ks-sdk-core' +export enum LimitOrderTab { + ORDER_BOOK = 'order_book', + MY_ORDER = 'my_order', +} + export enum LimitOrderStatus { // status from BE ACTIVE = 'active', @@ -48,6 +53,45 @@ export type LimitOrder = { txHash: string } +export type LimitOrderFromTokenPair = { + id: number + chainId: ChainId + signature: string + salt: string + makerAsset: string + takerAsset: string + maker: string + contractAddress: string + receiver: string + allowedSenders: string + makingAmount: string + takingAmount: string + filledMakingAmount: string + filledTakingAmount: string + feeConfig: string + feeRecipient: string + makerTokenFeePercent: string + makerAssetData: string + takerAssetData: string + getMakerAmount: string + getTakerAmount: string + predicate: string + permit: string + interaction: string + expiredAt: number + orderHash: string + availableMakingAmount: string + makerBalanceAllowance: string +} + +export type LimitOrderFromTokenPairFormatted = { + id: number + rate: string + firstAmount: string + secondAmount: string + filled: string +} + export enum CancelOrderType { GAS_LESS_CANCEL, HARD_CANCEL, diff --git a/src/constants/index.ts b/src/constants/index.ts index 88f040ee56..aaa49982ca 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -225,6 +225,7 @@ export const RTK_QUERY_TAGS = { // limit order GET_LIST_ORDERS: 'GET_LIST_ORDERS', + GET_ORDERS_BY_TOKEN_PAIR: 'GET_ORDERS_BY_TOKEN_PAIR', GET_FARM_V2: 'GET_FARM_V2', } diff --git a/src/hooks/useBaseTradeInfo.ts b/src/hooks/useBaseTradeInfo.ts index 48b4de26ab..5e9b4d9376 100644 --- a/src/hooks/useBaseTradeInfo.ts +++ b/src/hooks/useBaseTradeInfo.ts @@ -53,9 +53,9 @@ export function useBaseTradeInfoLimitOrder( currencyOut: Currency | undefined, chainId?: ChainId, ) { - const { loading, tradeInfo } = useBaseTradeInfo(currencyIn, currencyOut, chainId) + const { loading, tradeInfo, refetch } = useBaseTradeInfo(currencyIn, currencyOut, chainId) const debouncedLoading = useDebounce(loading, 100) // prevent flip flop UI when loading from true to false - return { loading: loading || debouncedLoading, tradeInfo } + return { loading: loading || debouncedLoading, tradeInfo, refetch } } export const useBaseTradeInfoWithAggregator = (args: ArgsGetRoute) => { diff --git a/src/pages/PartnerSwap/index.tsx b/src/pages/PartnerSwap/index.tsx index 11dfafe85b..a38c8bb762 100644 --- a/src/pages/PartnerSwap/index.tsx +++ b/src/pages/PartnerSwap/index.tsx @@ -12,7 +12,7 @@ import SwapForm, { SwapFormProps } from 'components/SwapForm' import { SwitchLocaleLink } from 'components/SwitchLocaleLink' import { TutorialKeys } from 'components/Tutorial/TutorialSwap' import LimitOrderForm from 'components/swapv2/LimitOrder/LimitOrderForm' -import ListLimitOrder from 'components/swapv2/LimitOrder/ListOrder' +import ListLimitOrder from 'components/swapv2/LimitOrder/ListLimitOrder' import Tutorial from 'components/swapv2/LimitOrder/Tutorial' import LiquiditySourcesPanel from 'components/swapv2/LiquiditySourcesPanel' import SettingsPanel from 'components/swapv2/SwapSettingsPanel' diff --git a/src/pages/SwapV3/index.tsx b/src/pages/SwapV3/index.tsx index 6a6ed720de..bddfd6841b 100644 --- a/src/pages/SwapV3/index.tsx +++ b/src/pages/SwapV3/index.tsx @@ -11,7 +11,7 @@ import { SwitchLocaleLink } from 'components/SwitchLocaleLink' import { TutorialIds } from 'components/Tutorial/TutorialSwap/constant' import GasTokenSetting from 'components/swapv2/GasTokenSetting' import LimitOrder from 'components/swapv2/LimitOrder' -import ListLimitOrder from 'components/swapv2/LimitOrder/ListOrder' +import ListLimitOrder from 'components/swapv2/LimitOrder/ListLimitOrder' import LiquiditySourcesPanel from 'components/swapv2/LiquiditySourcesPanel' import SettingsPanel from 'components/swapv2/SwapSettingsPanel' import TokenInfoTab from 'components/swapv2/TokenInfo' @@ -100,6 +100,7 @@ export default function Swap() { const { pathname } = useLocation() const [searchParams] = useSearchParams() const navigate = useNavigate() + useEffect(() => { const inputCurrency = searchParams.get('inputCurrency') const outputCurrency = searchParams.get('outputCurrency') diff --git a/src/services/limitOrder.ts b/src/services/limitOrder.ts index 1935f47102..e0cae43855 100644 --- a/src/services/limitOrder.ts +++ b/src/services/limitOrder.ts @@ -1,12 +1,13 @@ import { ChainId } from '@kyberswap/ks-sdk-core' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' -import { LimitOrder, LimitOrderStatus } from 'components/swapv2/LimitOrder/type' +import { LimitOrder, LimitOrderFromTokenPair, LimitOrderStatus } from 'components/swapv2/LimitOrder/type' import { LIMIT_ORDER_API } from 'constants/env' import { RTK_QUERY_TAGS } from 'constants/index' const LIMIT_ORDER_API_READ = `${LIMIT_ORDER_API}/read-ks/api` const LIMIT_ORDER_API_WRITE = `${LIMIT_ORDER_API}/write/api` +const LIMIT_ORDER_API_READ_PARTNER = `${LIMIT_ORDER_API}/read-partner/api` const mapPath: Partial> = { [LimitOrderStatus.CANCELLED]: 'cancelled', @@ -19,7 +20,7 @@ const transformResponse = (data: any) => data?.data const limitOrderApi = createApi({ reducerPath: 'limitOrderApi', baseQuery: fetchBaseQuery({ baseUrl: '' }), - tagTypes: [RTK_QUERY_TAGS.GET_LIST_ORDERS], + tagTypes: [RTK_QUERY_TAGS.GET_LIST_ORDERS, RTK_QUERY_TAGS.GET_ORDERS_BY_TOKEN_PAIR], endpoints: builder => ({ getLOConfig: builder.query< { contract: string; features: { [address: string]: { supportDoubleSignature: boolean } } }, @@ -60,6 +61,26 @@ const limitOrderApi = createApi({ }, providesTags: [RTK_QUERY_TAGS.GET_LIST_ORDERS], }), + getOrdersByTokenPair: builder.query< + { orders: LimitOrderFromTokenPair[] }, + { + chainId: ChainId + makerAsset?: string + takerAsset?: string + } + >({ + query: params => ({ + url: `${LIMIT_ORDER_API_READ_PARTNER}/v1/orders`, + params, + }), + transformResponse: ({ data }: any) => { + data.orders.forEach((order: any) => { + order.chainId = Number(order.chainId) as ChainId + }) + return { orders: data?.orders || [] } + }, + providesTags: [RTK_QUERY_TAGS.GET_ORDERS_BY_TOKEN_PAIR], + }), getNumberOfInsufficientFundOrders: builder.query({ query: params => ({ url: `${LIMIT_ORDER_API_READ}/v1/orders/insufficient-funds`, @@ -156,6 +177,7 @@ const limitOrderApi = createApi({ export const { useGetLOConfigQuery, useGetListOrdersQuery, + useGetOrdersByTokenPairQuery, useLazyGetListOrdersQuery, useInsertCancellingOrderMutation, useGetNumberOfInsufficientFundOrdersQuery, diff --git a/src/types/object.d.ts b/src/types/object.d.ts index 2bb60cea06..fa8c73f1b5 100644 --- a/src/types/object.d.ts +++ b/src/types/object.d.ts @@ -4,4 +4,23 @@ interface ObjectConstructor { * @param o Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object. */ keys(o: { [keys in T]: unknown }): T[] + + /** + * Groups members of an iterable according to the return value of the passed callback. + * @param items An iterable. + * @param keySelector A callback which will be invoked for each item in items. + */ + groupBy( + items: Iterable, + keySelector: (item: T, index: number) => K, + ): Partial> +} + +interface MapConstructor { + /** + * Groups members of an iterable according to the return value of the passed callback. + * @param items An iterable. + * @param keySelector A callback which will be invoked for each item in items. + */ + groupBy(items: Iterable, keySelector: (item: T, index: number) => K): Map }