diff --git a/src/apps/explorer/components/ActiveSolversTableWidget/index.tsx b/src/apps/explorer/components/ActiveSolversTableWidget/index.tsx index 09890462f..2fc41b2f7 100644 --- a/src/apps/explorer/components/ActiveSolversTableWidget/index.tsx +++ b/src/apps/explorer/components/ActiveSolversTableWidget/index.tsx @@ -6,6 +6,7 @@ import { useFlexSearch } from 'hooks/useFlexSearch' import { Solver, useGetSolvers } from 'hooks/useGetSolvers' import { TableState } from 'hooks/useTable' import { ActiveSolversTableWithData } from 'apps/explorer/components/ActiveSolversTableWidget/ActiveSolversTableWithData' +import { TabView } from 'apps/explorer/pages/Solver' import { ConnectionStatus } from 'components/ConnectionStatus' import { CardRow } from 'components/common/CardRow' import TablePagination, { PaginationWrapper } from '../common/TablePagination' @@ -46,12 +47,16 @@ export const ActiveSolversTableWidget: React.FC = ({ query, tableState, s useEffect(() => { const response = query ? (filteredSolvers as Solver[]) : solvers - setTableValues({ - data: response.slice(tableState.pageOffset, tableState.pageOffset + tableState.pageSize), - length: query ? filteredSolvers.length : solvers.length, - isLoading, - error, - }) + setTableValues((prevState: unknown[]) => ({ + ...prevState, + [TabView.ACTIVE_SOLVERS]: { + data: response.slice(tableState.pageOffset, tableState.pageOffset + tableState.pageSize), + rawData: solvers, + length: query ? filteredSolvers.length : solvers.length, + isLoading, + error, + }, + })) }, [error, filteredSolvers, isLoading, query, setTableValues, solvers, tableState.pageOffset, tableState.pageSize]) if (isLoading) { diff --git a/src/apps/explorer/components/SettlementsTableWidget/SettlementsTableWithData.tsx b/src/apps/explorer/components/SettlementsTableWidget/SettlementsTableWithData.tsx new file mode 100644 index 000000000..0cfeefb20 --- /dev/null +++ b/src/apps/explorer/components/SettlementsTableWidget/SettlementsTableWithData.tsx @@ -0,0 +1,41 @@ +import React, { useContext, useState, useEffect } from 'react' + +import { EmptyItemWrapper } from 'components/common/StyledUserDetailsTable' +import useFirstRender from 'hooks/useFirstRender' +import { SettlementsTableContext } from 'apps/explorer/components/SettlementsTableWidget/context/SettlementsTableContext' +import SettlementTable from 'components/solver/SettlementTable' +import { DEFAULT_TIMEOUT } from 'const' +import CowLoading from 'components/common/CowLoading' + +export const SettlementsTableWithData: React.FC = () => { + const { data: settlements, networkId, tableState } = useContext(SettlementsTableContext) + const isFirstRender = useFirstRender() + const [isFirstLoading, setIsFirstLoading] = useState(true) + + useEffect(() => { + setIsFirstLoading(true) + }, [networkId]) + + useEffect(() => { + let timeOutMs = 0 + if (!settlements) { + timeOutMs = DEFAULT_TIMEOUT + } + + const timeOutId: NodeJS.Timeout = setTimeout(() => { + setIsFirstLoading(false) + }, timeOutMs) + + return (): void => { + clearTimeout(timeOutId) + } + }, [settlements, settlements?.length]) + + return isFirstRender || isFirstLoading ? ( + + + + ) : ( + + ) +} diff --git a/src/apps/explorer/components/SettlementsTableWidget/context/SettlementsTableContext.tsx b/src/apps/explorer/components/SettlementsTableWidget/context/SettlementsTableContext.tsx new file mode 100644 index 000000000..a6f4302a8 --- /dev/null +++ b/src/apps/explorer/components/SettlementsTableWidget/context/SettlementsTableContext.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +import { Network, UiError } from 'types' +import { Settlement } from 'hooks/useGetSettlements' +import { TableState, TableStateSetters } from 'hooks/useTable' + +export type BlockchainNetwork = Network | undefined + +type CommonState = { + error?: UiError + data: Settlement[] | undefined + networkId: BlockchainNetwork + isLoading: boolean + tableState: TableState +} & TableStateSetters + +export const SettlementsTableContext = React.createContext({} as CommonState) diff --git a/src/apps/explorer/components/SettlementsTableWidget/index.tsx b/src/apps/explorer/components/SettlementsTableWidget/index.tsx new file mode 100644 index 000000000..50ca52077 --- /dev/null +++ b/src/apps/explorer/components/SettlementsTableWidget/index.tsx @@ -0,0 +1,88 @@ +import React, { useEffect } from 'react' +import styled from 'styled-components' +import { SettlementsTableContext, BlockchainNetwork } from './context/SettlementsTableContext' +import { useNetworkId } from 'state/network' +import { useFlexSearch } from 'hooks/useFlexSearch' +import { Settlement, useGetSettlements } from 'hooks/useGetSettlements' +import { TableState } from 'hooks/useTable' +import { SettlementsTableWithData } from 'apps/explorer/components/SettlementsTableWidget/SettlementsTableWithData' +import { TabView } from 'apps/explorer/pages/Solver' +import { ConnectionStatus } from 'components/ConnectionStatus' +import { CardRow } from 'components/common/CardRow' +import TablePagination, { PaginationWrapper } from '../common/TablePagination' +import { DropdownDirection } from '../common/Dropdown' +import { media } from 'theme/styles/media' +import CowLoading from 'components/common/CowLoading' +import { EmptyItemWrapper } from 'components/common/StyledUserDetailsTable' +import { ScrollBarStyle } from 'apps/explorer/styled' + +const TableWrapper = styled(CardRow)` + width: 100%; + ${media.mobile} { + width: 100%; + } + ${PaginationWrapper} { + width: 100%; + justify-content: flex-end; + } + div.tab-content { + padding: 0 !important; + table { + ${ScrollBarStyle} + } + } +` +interface Props { + networkId: BlockchainNetwork + query: string + tableState: TableState + data: Settlement[] + setTableValues: (data: unknown) => void +} + +export const SettlementsTableWidget: React.FC = ({ query, tableState, setTableValues, data }) => { + const networkId = useNetworkId() || undefined + const { settlements, isLoading, error } = useGetSettlements(networkId, data) + const filteredSettlements = useFlexSearch(query, settlements, ['name', 'txHash', 'address']) + + useEffect(() => { + const response = query ? (filteredSettlements as Settlement[]) : settlements + setTableValues((prevState: unknown[]) => ({ + ...prevState, + [TabView.SETTLEMENTS]: { + data: response.slice(tableState.pageOffset, tableState.pageOffset + tableState.pageSize), + rawData: settlements, + length: query ? filteredSettlements.length : settlements.length, + isLoading, + error, + }, + })) + }, [ + error, + filteredSettlements, + isLoading, + query, + setTableValues, + settlements, + tableState.pageOffset, + tableState.pageSize, + ]) + + if (isLoading) { + return ( + + + + ) + } + + return ( + + + + + + ) +} + +export default SettlementsTableWidget diff --git a/src/apps/explorer/pages/Solver/data.ts b/src/apps/explorer/pages/Solver/data.ts new file mode 100644 index 000000000..b3afdbc18 --- /dev/null +++ b/src/apps/explorer/pages/Solver/data.ts @@ -0,0 +1,106 @@ +export const ACTIVE_SOLVERS = { + '0x0e8f282ce027f3ac83980e6020a2463f4c841264': { + name: 'Legacy', + environment: 'prod', + }, + '0x7a0a8890d71a4834285efdc1d18bb3828e765c6a': { + name: 'Naive', + environment: 'prod', + }, + '0x3cee8c7d9b5c8f225a8c36e7d3514e1860309651': { + name: 'Baseline', + environment: 'prod', + }, + '0xe8ff24ec26bd46e0140d1824da44efad2a0920b5': { + name: 'MIP', + environment: 'prod', + }, + '0x731a0a8ab2c6fcad841e82d06668af7f18e34970': { + name: 'QuasiModo', + environment: 'prod', + }, + '0xb20b86c4e6deeb432a22d773a221898bbbd03036': { + name: 'Gnosis_1inch', + environment: 'prod', + }, + '0xe9ae2d792f981c53ea7f6493a17abf5b2a45a86b': { + name: 'Gnosis_ParaSwap', + environment: 'prod', + }, + '0xda869be4adea17ad39e1dfece1bc92c02491504f': { + name: 'Gnosis_0x', + environment: 'prod', + }, + '0x6d1247b8acf4dfd5ff8cfd6c47077ddc43d4500e': { + name: 'DexCowAgg', + environment: 'prod', + }, + '0xf7995b6b051166ea52218c37b8d03a2a6bbef3da': { + name: 'Gnosis_BalancerSOR', + environment: 'prod', + }, + '0xc9ec550bea1c64d779124b23a26292cc223327b6': { + name: 'Otex', + environment: 'prod', + }, + '0x149d0f9282333681ee41d30589824b2798e9fb47': { + name: 'PLM', + environment: 'prod', + }, + '0xa21740833858985e4d801533a808786d3647fb83': { + name: 'Laertes', + environment: 'prod', + }, + '0x0a308697e1d3a91dcb1e915c51f8944aaec9015f': { + name: 'Laertes', + environment: 'barn', + }, + '0x109bf9e0287cc95cc623fbe7380dd841d4bdeb03': { + name: 'Otex', + environment: 'barn', + }, + '0xed94b86275447e28ddbdd17bbeb1f62d607b5119': { + name: 'Legacy', + environment: 'barn', + }, + '0x8ccc61dba297833dbe5d95fd6360f106b9a7576e': { + name: 'Naive', + environment: 'barn', + }, + '0x0d2584da2f637805071f184bcfa1268ebae8a24a': { + name: 'Baseline', + environment: 'barn', + }, + '0xa0044c620da7f2876da7004719b8370eb7be5e50': { + name: 'MIP', + environment: 'barn', + }, + '0xda324c2f06d3544e7965767ce9ca536dcb67a660': { + name: 'QuasiModo', + environment: 'barn', + }, + '0xe33062a24149f7801a48b2675ed5111d3278f0f5': { + name: 'Gnosis_1inch', + environment: 'barn', + }, + '0x080a8b1e2f3695e179453c5e617b72a381be44b9': { + name: 'Gnosis_ParaSwap', + environment: 'barn', + }, + '0xde786877a10dbb7eba25a4da65aecf47654f08ab': { + name: 'Gnosis_0x', + environment: 'barn', + }, + '0xdae69affe582d36f330ee1145995a53fab670962': { + name: 'DexCowAgg', + environment: 'barn', + }, + '0x22dee0935c77d32c7241362b14e76fc2d5ef657d': { + name: 'Gnosis_BalancerSOR', + environment: 'barn', + }, + '0x5b0bfe439ab45a4f002c259b1654ed21c9a42d69': { + name: 'PLM', + environment: 'barn', + }, +} diff --git a/src/apps/explorer/pages/Solver/index.tsx b/src/apps/explorer/pages/Solver/index.tsx index 2b3b51e90..062111295 100644 --- a/src/apps/explorer/pages/Solver/index.tsx +++ b/src/apps/explorer/pages/Solver/index.tsx @@ -3,12 +3,15 @@ import { useHistory } from 'react-router-dom' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { useQuery } from 'hooks/useQuery' import { Solver as SolverType } from 'hooks/useGetSolvers' +import { Settlement as SettlementType } from 'hooks/useGetSettlements' import { TableState, useTable } from 'hooks/useTable' import { useNetworkId } from 'state/network' import { ActiveSolversTableContext } from 'apps/explorer/components/ActiveSolversTableWidget/context/ActiveSolversTableContext' +import { SettlementsTableContext } from 'apps/explorer/components/SettlementsTableWidget/context/SettlementsTableContext' import { TableSearch } from 'components/common/TableSearch/TableSearch' import TablePagination from 'apps/explorer/components/common/TablePagination' import ActiveSolversTableWidget from 'apps/explorer/components/ActiveSolversTableWidget' +import SettlementsTableWidget from 'apps/explorer/components/SettlementsTableWidget' import { TabItemInterface } from 'components/common/Tabs/Tabs' import { ContentCard as Content, Title } from 'apps/explorer/pages/styled' @@ -21,8 +24,22 @@ export enum TabView { SETTLEMENTS = 2, } +const DEFAULT_TABLE_INFO: { data: unknown[]; rawData: unknown[]; isLoading: boolean; length: number; error?: UiError } = + { + data: [], + rawData: [], + isLoading: false, + length: 0, + error: undefined, + } + const DEFAULT_TAB = TabView[1] +const SEARCH_PLACEHOLDERS = { + [TabView.ACTIVE_SOLVERS]: 'Search by solver name or address', + [TabView.SETTLEMENTS]: 'Search by tx hash, solver name or address', +} + function useQueryViewParams(): { tab: string } { const query = useQuery() return { tab: query.get('tab')?.toUpperCase() || DEFAULT_TAB } // if URL param empty will be used DEFAULT @@ -31,7 +48,7 @@ function useQueryViewParams(): { tab: string } { const tabItems = ( networkId: SupportedChainId | undefined, query: string, - setTableValues: (data: { data: unknown[]; length: number; isLoading: boolean; error?: UiError }) => void, + setTableValues: (data: unknown) => void, data: unknown[], tableState: TableState, ): TabItemInterface[] => { @@ -52,7 +69,15 @@ const tabItems = ( { id: TabView.SETTLEMENTS, tab: Settlements, - content: <>, + content: ( + + ), }, ] } @@ -70,22 +95,16 @@ const Solver: React.FC = () => { handleNextPage, handlePreviousPage, } = useTable({ initialState: { pageOffset: 0, pageSize: RESULTS_PER_PAGE } }) - const [tableValues, setTableValues] = useState<{ - data: Array - length: number - isLoading: boolean - error?: UiError - }>({ - data: [], - isLoading: false, - length: 0, - error: undefined, + const [tableValues, setTableValues] = useState({ + [TabView.ACTIVE_SOLVERS]: DEFAULT_TABLE_INFO, + [TabView.SETTLEMENTS]: DEFAULT_TABLE_INFO, }) const [query, setQuery] = useState('') const networkId = useNetworkId() || undefined - tableState['hasNextPage'] = tableState.pageOffset + tableState.pageSize < tableValues.length - tableState['totalResults'] = tableValues.length + tableState['hasNextPage'] = tableState.pageOffset + tableState.pageSize < tableValues[tabViewSelected].length + tableState['totalResults'] = tableValues[tabViewSelected].length + const TableContext = tabViewSelected === TabView.ACTIVE_SOLVERS ? ActiveSolversTableContext : SettlementsTableContext useEffect(() => { if (query.length) { setPageOffset(0) @@ -95,40 +114,15 @@ const Solver: React.FC = () => { useEffect(() => { setQuery('') setPageOffset(0) - }, [networkId, setPageOffset, setQuery]) - - tableState['hasNextPage'] = tableState.pageOffset + tableState.pageSize < tableValues.length - tableState['totalResults'] = tableValues.length - - useEffect(() => { - if (query.length) { - setPageOffset(0) - } - }, [query, setPageOffset]) - - useEffect(() => { - setQuery('') - setPageOffset(0) - }, [networkId, setPageOffset, setQuery]) + }, [networkId, setPageOffset, setQuery, tabViewSelected]) const ExtraComponentNode: React.ReactNode = ( - - + + ) - useEffect(() => { - if (query.length) { - setPageOffset(0) - } - }, [query, setPageOffset]) - - useEffect(() => { - setQuery('') - setPageOffset(0) - }, [networkId, setPageOffset, setQuery]) - const onChangeTab = useCallback((tabId: number) => { const newTabViewName = TabView[tabId] if (!newTabViewName) return @@ -143,11 +137,11 @@ const Solver: React.FC = () => { Solvers - { > onChangeTab(key)} extra={ExtraComponentNode} /> - + ) diff --git a/src/apps/explorer/pages/Solver/styled.ts b/src/apps/explorer/pages/Solver/styled.ts index 711f2ac8a..ffdc4c757 100644 --- a/src/apps/explorer/pages/Solver/styled.ts +++ b/src/apps/explorer/pages/Solver/styled.ts @@ -11,7 +11,6 @@ import { TabList } from 'components/common/Tabs/Tabs' export const StyledExplorerTabs = styled(ExplorerTabs)` border: 0; ${TabList} > button { - padding: 1rem 1.5rem; ${media.mobile} { font-size: 1.5rem; margin: 0; @@ -59,7 +58,8 @@ export const Wrapper = styled(WrapperTemplate)` } } .solvers-tab { - &--active_solvers { + &--active_solvers, + &--settlements { .tab-content { padding: 0; } diff --git a/src/components/common/BlockExplorerLink/BlockExplorerLink.tsx b/src/components/common/BlockExplorerLink/BlockExplorerLink.tsx index dd3deb444..6b56d596b 100644 --- a/src/components/common/BlockExplorerLink/BlockExplorerLink.tsx +++ b/src/components/common/BlockExplorerLink/BlockExplorerLink.tsx @@ -25,6 +25,10 @@ export interface Props { * label to replace textContent generated from identifier */ label?: string | ReactElement | void + /** + * children Jsx.Element + */ + children?: JSX.Element /** * Use the URL as a label @@ -105,7 +109,16 @@ function getExplorerUrl(networkId: number, type: BlockExplorerLinkType, identifi * Expects all data as input. Does not use any hooks internally. */ export const BlockExplorerLink: React.FC = (props: Props) => { - const { type, identifier, label: labelProp, useUrlAsLabel = false, className, networkId, showLogo = false } = props + const { + type, + identifier, + label: labelProp, + useUrlAsLabel = false, + className, + networkId, + children, + showLogo = false, + } = props if (!networkId || !identifier) { return null @@ -116,8 +129,12 @@ export const BlockExplorerLink: React.FC = (props: Props) => { return ( - {label} - {showLogo && } + {children || ( + <> + {label} + {showLogo && } + + )} ) } diff --git a/src/components/common/TokenDisplay/index.tsx b/src/components/common/TokenDisplay/index.tsx index cbbbce0f3..9bd7af7c5 100644 --- a/src/components/common/TokenDisplay/index.tsx +++ b/src/components/common/TokenDisplay/index.tsx @@ -8,7 +8,7 @@ import { Network } from 'types' import { BlockExplorerLink } from 'components/common/BlockExplorerLink' import TokenImg from 'components/common/TokenImg' -export type Props = { erc20: TokenErc20; network: Network; showAbbreviated?: boolean } +export type Props = { erc20: TokenErc20; network: Network; showAbbreviated?: boolean; hideLabel?: boolean } const Wrapper = styled.div` display: flex; @@ -28,7 +28,7 @@ const StyledImg = styled(TokenImg)` ` export function TokenDisplay(props: Props): JSX.Element { - const { erc20, network, showAbbreviated } = props + const { erc20, network, showAbbreviated, hideLabel = false } = props // Name and symbol are optional on ERC20 spec. Fallback to address when no name, // and show no symbol when that's not set @@ -40,7 +40,7 @@ export function TokenDisplay(props: Props): JSX.Element { return ( - {isNativeToken(erc20.address) ? ( + {hideLabel ? null : isNativeToken(erc20.address) ? ( // There's nowhere to link when it's a native token, so, only display the symbol {erc20.symbol} ) : ( diff --git a/src/components/common/TokensVisualizer/index.tsx b/src/components/common/TokensVisualizer/index.tsx new file mode 100644 index 000000000..f13471fbb --- /dev/null +++ b/src/components/common/TokensVisualizer/index.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import styled from 'styled-components' +import { TokenErc20 } from '@gnosis.pm/dex-js' +import { Network } from 'types' +import { getNativeTokenName, isNativeToken } from 'utils' + +import { useTokenList } from 'hooks/useTokenList' +import { media } from 'theme/styles/media' +import { TextWithTooltip } from 'apps/explorer/components/common/TextWithTooltip' +import { BlockExplorerLink } from 'components/common/BlockExplorerLink' +import { TokenDisplay } from 'components/common/TokenDisplay' + +const MAX_AMOUNT = 4 +export type Props = { tokens: TokenErc20[]; network: Network; amountDisplayed?: number } + +const Wrapper = styled.div<{ amount: number }>` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10px, max-content)); + & > div:not(.token-number) { + width: 5rem; + ${media.mobile} { + width: ${(props): string | 'auto' => (props.amount >= 3 ? '3rem' : 'auto')}; + margin-right: ${(props): string | 'auto' => (props.amount >= 3 ? `${props.amount}rem` : 'none')}; + } + } + img { + margin: 0; + width: 3rem; + height: 3rem; + box-shadow: ${({ theme }): string => theme.boxShadow}; + border: 1px solid ${({ theme }): string => theme.bg2}; + } +` +const TokenNumber = styled.div` + width: 3rem; + height: 3rem; + border-radius: 50%; + background: ${({ theme }): string => theme.grey1}; + display: flex; + align-items: center; + justify-content: center; +` + +export function TokensVisualizer(props: Props): JSX.Element { + const { tokens, network, amountDisplayed = MAX_AMOUNT } = props + const { tokens: tokenList } = useTokenList({ networkId: network }) + const mappedTokens = tokens.map((t) => { + const isNative = isNativeToken(t.address) + if (isNative) { + const { nativeToken } = getNativeTokenName(network) + return { ...t, symbol: nativeToken } + } + return t + }) + + const getToken = (address: string): string => { + const token = tokenList.find((t) => t.address.toLowerCase() === address.toLowerCase()) + return token?.symbol || 'Unknown' + } + + const tokensLeft = mappedTokens.slice(amountDisplayed, tokens.length) + return ( + + {mappedTokens.slice(0, amountDisplayed).map((token) => ( + + + + + + ))} + {tokensLeft.length > 0 && ( + t.symbol).join(',')}> + + +{tokensLeft.length} + + + )} + + ) +} diff --git a/src/components/solver/ActiveSolverTable/index.tsx b/src/components/solver/ActiveSolverTable/index.tsx index dcb9bcbfd..3428248fc 100644 --- a/src/components/solver/ActiveSolverTable/index.tsx +++ b/src/components/solver/ActiveSolverTable/index.tsx @@ -179,7 +179,7 @@ interface RowProps { } const RowSolver: React.FC = ({ solver }) => { - const { id, name, address, numberOfTrades, solvedAmountUsd } = solver + const { id, name, address, numberOfTrades, numberOfSettlements, solvedAmountUsd } = solver const network = useNetworkId() if (!network) { @@ -199,11 +199,7 @@ const RowSolver: React.FC = ({ solver }) => { Trades - - - {numberFormatter(numberOfTrades)} - - + {numberFormatter(numberOfTrades)} Total volume @@ -217,12 +213,7 @@ const RowSolver: React.FC = ({ solver }) => { Total settlements - - - - {/* - {numberFormatter(numberOfSettlements)} - */} - + {numberOfSettlements} Solver address diff --git a/src/components/solver/SettlementTable/index.tsx b/src/components/solver/SettlementTable/index.tsx new file mode 100644 index 000000000..56a998231 --- /dev/null +++ b/src/components/solver/SettlementTable/index.tsx @@ -0,0 +1,313 @@ +import React from 'react' +import styled from 'styled-components' +import BigNumber from 'bignumber.js' +import { formatPrice } from '@gnosis.pm/dex-js' +import { useNetworkId } from 'state/network' +import { abbreviateString } from 'utils' +import { Settlement } from 'hooks/useGetSettlements' +import { TableState } from 'hooks/useTable' + +import StyledUserDetailsTable, { + StyledUserDetailsTableProps, + EmptyItemWrapper, +} from '../../common/StyledUserDetailsTable' + +import { media } from 'theme/styles/media' +import { BlockExplorerLink } from 'components/common/BlockExplorerLink' +import { LinkWithPrefixNetwork } from 'components/common/LinkWithPrefixNetwork' +import { DateDisplay } from 'components/common/DateDisplay' +import { RowWithCopyButton } from 'components/common/RowWithCopyButton' +import { TokensVisualizer } from 'components/common/TokensVisualizer' +import Identicon from 'components/common/Identicon' +import { numberFormatter } from 'apps/explorer/components/SummaryCardsWidget/utils' +import { TextWithTooltip } from 'apps/explorer/components/common/TextWithTooltip' + +const Wrapper = styled(StyledUserDetailsTable)` + > thead { + > tr > th:first-child { + padding: 0 2rem; + } + } + > tbody { + min-height: 37rem; + border-bottom: 0.1rem solid ${({ theme }): string => theme.tableRowBorder}; + > tr { + min-height: 7.4rem; + &.header-row { + display: none; + ${media.mobile} { + display: flex; + background: transparent; + border: none; + padding: 0; + margin: 0; + box-shadow: none; + min-height: 2rem; + td { + padding: 0; + margin: 0; + margin-top: 1rem; + .mobile-header { + margin: 0; + } + } + } + } + } + > tr > td:first-child { + padding: 0 2rem; + } + } + > thead > tr, + > tbody > tr { + grid-template-columns: 3fr 2fr 1fr 1fr 1fr 1fr 2fr; + } + > tbody > tr > td:nth-child(8), + > thead > tr > th:nth-child(8) { + justify-content: center; + } + tr > td { + span.span-inside-tooltip { + display: flex; + flex-direction: row; + flex-wrap: wrap; + img { + padding: 0; + } + } + } + ${media.mobile} { + > thead > tr { + display: none; + + > th:first-child { + padding: 0 1rem; + } + } + > tbody > tr { + grid-template-columns: none; + border: 0.1rem solid ${({ theme }): string => theme.tableRowBorder}; + box-shadow: 0px 4px 12px ${({ theme }): string => theme.boxShadow}; + border-radius: 6px; + margin-top: 10px; + padding: 12px; + &:hover { + background: none; + backdrop-filter: none; + } + + td:first-child { + padding: 0 1rem; + } + } + tr > td { + display: flex; + flex: 1; + width: 100%; + justify-content: space-between; + margin: 0; + margin-bottom: 18px; + min-height: 32px; + span.span-inside-tooltip { + align-items: flex-end; + flex-direction: column; + img { + margin-left: 0; + } + } + } + > tbody > tr > td, + > thead > tr > th { + :nth-child(4), + :nth-child(5), + :nth-child(6), + :nth-child(7), + :nth-child(8) { + justify-content: space-between; + } + } + .header-value { + flex-wrap: wrap; + text-align: end; + } + .span-copybtn-wrap { + display: flex; + flex-wrap: nowrap; + span { + display: flex; + align-items: center; + } + .copy-text { + display: none; + } + } + } + overflow: auto; +` + +const HeaderTitle = styled.span` + display: none; + ${media.mobile} { + font-weight: 600; + align-items: center; + display: flex; + margin-right: 3rem; + svg { + margin-left: 5px; + } + } +` +const HeaderValue = styled.span<{ captionColor?: 'green' | 'red1' | 'grey' }>` + color: ${({ theme, captionColor }): string => (captionColor ? theme[captionColor] : theme.textPrimary1)}; + ${media.mobile} { + flex-wrap: wrap; + text-align: end; + } +` +const IdenticonWrapper = styled.div` + display: flex; + flex: 1; + align-items: center; + gap: 1rem; +` + +export type Props = StyledUserDetailsTableProps & { + settlements: Settlement[] | undefined + tableState: TableState +} + +interface RowProps { + index: number + settlement: Settlement +} + +const RowSettlement: React.FC = ({ settlement }) => { + const { + id, + name, + solver: { address }, + txHash, + trades = [], + tokens = [], + totalVolumeUsd, + firstTradeTimestamp, + } = settlement + const network = useNetworkId() + + if (!network) { + return null + } + + return ( + + + Name + + + + + + + + + Tx hash + + + {abbreviateString(txHash, 6, 4)} + + } + /> + + + + Trades + + + {numberFormatter(trades.length)} + + + + + Tokens + + + + + + ETH cost + - + + + Total volume + + + ${Number(totalVolumeUsd) ? numberFormatter(totalVolumeUsd) : 0} + + + + + Timestamp + + + + + + ) +} + +const SettlementTable: React.FC = (props) => { + const { settlements, tableState, showBorderTable = false } = props + const settlementItems = (items: Settlement[] | undefined): JSX.Element => { + let tableContent + if (!items || items.length === 0) { + tableContent = ( + + + + No results found.
Please try another search. +
+ + + ) + } else { + tableContent = ( + <> + + + Sorted by Timestamp: from newest to oldest + + + {items.map((item, i) => ( + + ))} + + ) + } + return tableContent + } + + return ( + + Name + Tx hash + Trades + Tokens + ETH cost + Total volume + Timestamp↓ + + } + body={settlementItems(settlements)} + /> + ) +} + +export default SettlementTable diff --git a/src/components/token/TokenTable/index.tsx b/src/components/token/TokenTable/index.tsx index 2bd156c17..23959dfa2 100644 --- a/src/components/token/TokenTable/index.tsx +++ b/src/components/token/TokenTable/index.tsx @@ -87,7 +87,6 @@ const Wrapper = styled(StyledUserDetailsTable)` ${media.mobile} { > thead > tr { display: none; - > th:first-child { padding: 0 1rem; } @@ -103,7 +102,6 @@ const Wrapper = styled(StyledUserDetailsTable)` background: none; backdrop-filter: none; } - td:first-child { padding: 0 1rem; } diff --git a/src/hooks/useGetSettlements.ts b/src/hooks/useGetSettlements.ts new file mode 100644 index 000000000..72a275578 --- /dev/null +++ b/src/hooks/useGetSettlements.ts @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useState } from 'react' +import { gql } from '@apollo/client' +import { TokenErc20 } from '@gnosis.pm/dex-js' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { Network, UiError } from 'types' +import { COW_SDK } from 'const' +import { fetchSolversInfo } from 'utils/fetchSolversInfo' + +export const useGetSettlements = ( + networkId: SupportedChainId = SupportedChainId.MAINNET, + initData: Settlement[], +): GetSolverResult => { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState() + const [settlements, setSettlements] = useState(initData) + const shouldRefetch = !initData.length + + const fetchSettlements = useCallback( + async (network: Network): Promise => { + setIsLoading(true) + setSettlements([]) + try { + const response = await COW_SDK.cowSubgraphApi.runQuery<{ + settlements: Settlement[] + }>(GET_SETTLEMENTS_QUERY, undefined, { chainId: network }) + if (response) { + const settlementsWithInfo = await addExtraInfo(response.settlements, networkId) + setSettlements(settlementsWithInfo) + } + } catch (e) { + const msg = `Failed to fetch tokens` + console.error(msg, e) + setError({ message: msg, type: 'error' }) + } finally { + setIsLoading(false) + } + }, + [networkId], + ) + + useEffect(() => { + if (!networkId || !shouldRefetch) { + return + } + + fetchSettlements(networkId) + }, [fetchSettlements, networkId, shouldRefetch]) + + return { settlements, error, isLoading } +} + +const addExtraInfo = async (settlements: Settlement[], network: SupportedChainId): Promise => { + const solversInfo = await fetchSolversInfo(network) + return settlements.map((settlement) => { + const sInfo = solversInfo.find( + (s: { address: string }) => s.address.toLowerCase() === settlement.solver.address.toLowerCase(), + ) + const tokens: TokenErc20[] = [] + const addresses: string[] = [] + let totalVolumeUsd = 0 + let ethCost = 0 + settlement.trades.forEach((trade) => { + totalVolumeUsd += Number(trade.sellAmountUsd) || Number(trade.buyAmountUsd) + ethCost += Number(trade.feeAmount) + if (!addresses.includes(trade.buyToken.address)) { + tokens.push(trade.buyToken) + addresses.push(trade.buyToken.address) + } + if (!addresses.includes(trade.sellToken.address)) { + tokens.push(trade.sellToken) + addresses.push(trade.sellToken.address) + } + }) + return { + ...settlement, + ...sInfo, + address: sInfo?.address.toLowerCase(), + tokens, + ethCost, + totalVolumeUsd, + } + }) +} + +export type Settlement = { + id: string + name: string + firstTradeTimestamp: number + txHash: string + solver: { + address: string + } + trades: Trade[] + tokens: TokenErc20[] + totalVolumeUsd: number + ethCost: number +} + +type Trade = { + buyAmountUsd: string + sellAmountUsd: string + buyToken: TokenErc20 + sellToken: TokenErc20 + feeAmount: number +} + +export const GET_SETTLEMENTS_QUERY = gql` + query GetSettlements { + settlements(first: 1000, orderBy: firstTradeTimestamp, orderDirection: desc) { + id + firstTradeTimestamp + txHash + solver { + address + } + trades { + sellAmountUsd + buyAmountUsd + feeAmount + sellToken { + address + symbol + decimals + } + buyToken { + address + symbol + } + } + } + } +` + +type GetSolverResult = { + settlements: Settlement[] + error?: UiError + isLoading: boolean +} diff --git a/src/hooks/useGetSolvers.ts b/src/hooks/useGetSolvers.ts index 8093bc170..62567284c 100644 --- a/src/hooks/useGetSolvers.ts +++ b/src/hooks/useGetSolvers.ts @@ -3,6 +3,7 @@ import { gql } from '@apollo/client' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { Network, UiError } from 'types' import { COW_SDK } from 'const' +import { ACTIVE_SOLVERS } from 'apps/explorer/pages/Solver/data' import { fetchSolversInfo } from 'utils/fetchSolversInfo' export const useGetSolvers = ( @@ -19,9 +20,11 @@ export const useGetSolvers = ( setIsLoading(true) setSolvers([]) try { - const response = await COW_SDK.cowSubgraphApi.runQuery<{ - users: Pick[] - }>(GET_SOLVERS_QUERY, undefined, { chainId: network }) + const response = await COW_SDK.cowSubgraphApi.runQuery<{ users: Pick[] }>( + GET_SOLVERS_QUERY, + undefined, + { chainId: network }, + ) if (response) { const solversWithInfo = await addExtraInfo(response.users, networkId) const totalVolumeUsd = solversWithInfo.reduce((prev, current) => prev + Number(current.solvedAmountUsd), 0) @@ -46,7 +49,6 @@ export const useGetSolvers = ( if (!networkId || !shouldRefetch) { return } - fetchSolvers(networkId) }, [fetchSolvers, networkId, shouldRefetch]) @@ -54,25 +56,33 @@ export const useGetSolvers = ( } const addExtraInfo = async ( - solvers: Pick[], + solvers: Pick[], network: SupportedChainId, ): Promise => { const solversInfo = await fetchSolversInfo(network) - return solvers.map((solver) => { - const sInfo = solversInfo.find((s) => s.address.toLowerCase() === solver.address.toLowerCase()) - return { - ...solver, - ...sInfo, - numberOfSettlements: 0, - } - }) + return await Promise.all( + solvers.map(async (solver) => { + const { settlements } = await COW_SDK.cowSubgraphApi.runQuery<{ settlements: Settlement[] }>( + GET_SETTLEMENTS_QUERY, + { solver: solver.id }, + { chainId: network }, + ) + const sInfo = solversInfo.find((s) => s.address.toLowerCase() === solver.address.toLowerCase()) + return { + ...solver, + ...sInfo, + numberOfSettlements: settlements.length, + ...ACTIVE_SOLVERS[solver.address], + } + }), + ) } export type Solver = { id: string address: string - name?: string - environment?: string + name: string + environment: string numberOfTrades: number numberOfSettlements: number solvedAmountUsd: number diff --git a/src/theme/styles/colours.ts b/src/theme/styles/colours.ts index 5020edce2..6aed48e3f 100644 --- a/src/theme/styles/colours.ts +++ b/src/theme/styles/colours.ts @@ -38,6 +38,7 @@ export interface Colors { red3?: Color red4: Color grey: Color + grey1: Color greyShade: Color greyOpacity: Color green: Color @@ -81,6 +82,7 @@ export const BASE_COLOURS = { export const LIGHT_COLOURS = { //base grey: '#657795', + grey1: '#3D3D4D', greyShade: 'rgb(141 141 169 / 70%)', greyOpacity: 'rgb(141 141 169 / 10%)', green: '#1E9B75', @@ -119,6 +121,7 @@ export const LIGHT_COLOURS = { export const DARK_COLOURS = { // base grey: '#8D8DA9', + grey1: '#3D3D4D', greyShade: 'rgb(141 141 169 / 70%)', greyOpacity: 'rgb(141 141 169 / 10%)', green: '#00D897',