Commit c1f64db2 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into contract-write

parents 47e154d5 4750798b
......@@ -126,14 +126,16 @@ frontend:
_default: ENC[AES256_GCM,data:Hf4azYsGh2lysotK7afaHI85IaLBJeQmPlGE/lwokmX2eaQHVZJ/i5RsaoKoOCSiNyfAowr7P+6IBEC16BUTQMpbhYveBd7c/xjIsoomoHbTdoAdZA/QxNVpS4a2qVthruFudyT1BoZUHIGQ,iv:Mid+PfbslOyivrFSXopdeW96YvOcLP+g2RGcw1o7B98=,tag:IuPUsGdbZQodCMyi1DR04Q==,type:str]
NEXT_PUBLIC_AUTH0_CLIENT_ID:
_default: ENC[AES256_GCM,data:cRcbMzOW2AFyDz/lqv4T9SpDCabXdBKLFN9dbq/rFg4=,iv:G9afggfvZ+BpuE5u31KDVfIxhlP38IE28Yt9pMQsd6E=,tag:t1kkjXPolc/58fuQVULLfA==,type:str]
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID:
_default: ENC[AES256_GCM,data:LhVaS9L3ujRwznCe6D+edYU5XT4GLXm4jJ4lKGsrzyA=,iv:ibEJAfSrm3ZWtVJ5Du9MagbC6/Tv5L3xQQRjeQ1BGDA=,tag:IqtgpmJPhmHnSgMuIfI/0A==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age: []
lastmodified: "2022-11-17T09:16:34Z"
mac: ENC[AES256_GCM,data:kMQ1Hxpvdg90p54JD4SFMdPlH3bSi2L8QOX2d80ZFUpli2FYYrqlr4AA+cLLEoar9Vfs9yy5t8Wo0s4pjEJXnMd6hHdp8zon3Y99EO/6/+8O3nP/uvvRONrHy8gJHL+afbWWkmzTDE1gBgB3x7/06mVv2XWgXZfvr323f3yggzU=,iv:5ux8DilPzqzoRAxowl2EXYteg4Pjd8E5d4kb36LSKBU=,tag:TcX7FtlHIhfHZPWKxfqsgA==,type:str]
lastmodified: "2023-01-18T10:42:25Z"
mac: ENC[AES256_GCM,data:QZixaOd5zjucSuwtyBcgACtNynt2X23B6Dxqxm2ZQtsvwaqz51i7TOe1BlmORT+71KDJhnaDmodc+xcNAta0K0e8uS0qFvDaE3aew77yfpn02kM5/2PwYc2xlh7nKg6dsfddxERx5UzaWLQWnU7ODN7hpsZ3Q5Hurf9fI5APleI=,iv:XufptrfeRp63XuwLHEmUFUEi5kwsYtNNaJ63fyaLqOQ=,tag:oFOiQSlhPiw3I9Pe0lPaGw==,type:str]
pgp:
- created_at: "2022-09-14T13:42:28Z"
enc: |
......
This diff is collapsed.
......@@ -326,14 +326,14 @@ frontend:
resources:
limits:
memory:
_default: "0.3Gi"
_default: "0.1Gi"
cpu:
_default: "0.2"
_default: "0.1"
requests:
memory:
_default: "0.3Gi"
_default: "0.1Gi"
cpu:
_default: "0.2"
_default: "0.1"
# node label
nodeSelector:
enabled: true
......@@ -401,4 +401,3 @@ frontend:
_default: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: "[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]"
......@@ -11,6 +11,8 @@ import type {
AddressInternalTxsResponse,
AddressTxsFilters,
AddressTokenTransferFilters,
AddressTokensFilter,
AddressTokensResponse,
} from 'types/api/address';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
......@@ -20,7 +22,7 @@ import type { InternalTransactionsResponse } from 'types/api/internalTransaction
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { RawTracesResponse } from 'types/api/rawTrace';
import type { SearchResult, SearchResultFilters } from 'types/api/search';
import type { Stats, Charts, HomeStats } from 'types/api/stats';
import type { Counters, StatsCharts, StatsChart, HomeStats } from 'types/api/stats';
import type { TokenCounters, TokenInfo, TokenHolders } from 'types/api/tokenInfo';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
import type { TransactionsResponseValidated, TransactionsResponsePending, Transaction } from 'types/api/transaction';
......@@ -68,8 +70,13 @@ export const RESOURCES = {
endpoint: appConfig.statsApi.endpoint,
basePath: appConfig.statsApi.basePath,
},
stats_charts: {
path: '/api/v1/charts/line',
stats_lines: {
path: '/api/v1/lines',
endpoint: appConfig.statsApi.endpoint,
basePath: appConfig.statsApi.basePath,
},
stats_line: {
path: '/api/v1/lines/:id',
endpoint: appConfig.statsApi.endpoint,
basePath: appConfig.statsApi.basePath,
},
......@@ -163,6 +170,11 @@ export const RESOURCES = {
paginationFields: [ 'items_count' as const, 'transaction_index' as const, 'index' as const, 'block_number' as const ],
filterFields: [ ],
},
address_tokens: {
path: '/api/v2/addresses/:id/tokens',
paginationFields: [ 'items_count' as const, 'token_name' as const, 'token_type' as const, 'value' as const ],
filterFields: [ 'type' as const ],
},
// CONTRACT
contract: {
......@@ -264,8 +276,8 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'txs_validated' | 'txs_pending' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'address_logs' |
'search' |
'address_logs' | 'address_tokens' |
'token_holders';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
......@@ -285,8 +297,9 @@ Q extends 'homepage_chart_market' ? ChartMarketResponse :
Q extends 'homepage_blocks' ? Array<Block> :
Q extends 'homepage_txs' ? Array<Transaction> :
Q extends 'homepage_indexing_status' ? IndexingStatus :
Q extends 'stats_counters' ? Stats :
Q extends 'stats_charts' ? Charts :
Q extends 'stats_counters' ? Counters :
Q extends 'stats_lines' ? StatsCharts :
Q extends 'stats_line' ? StatsChart :
Q extends 'blocks' ? BlocksResponse :
Q extends 'block' ? Block :
Q extends 'block_txs' ? BlockTransactionsResponse :
......@@ -307,6 +320,7 @@ Q extends 'address_blocks_validated' ? AddressBlocksValidatedResponse :
Q extends 'address_coin_balance' ? AddressCoinBalanceHistoryResponse :
Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart :
Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'token' ? TokenInfo :
Q extends 'token_counters' ? TokenCounters :
Q extends 'token_holders' ? TokenHolders :
......@@ -326,6 +340,7 @@ Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters :
Q extends 'tx_token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
Q extends 'address_token_transfers' ? AddressTokenTransferFilters :
Q extends 'address_tokens' ? AddressTokensFilter :
Q extends 'search' ? SearchResultFilters :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -20,7 +20,7 @@ export default function useFetch() {
const { token } = queryClient.getQueryData<CsrfData>(getResourceKey('csrf')) || {};
return React.useCallback(<Success, Error>(path: string, params?: Params): Promise<Success | ResourceError<Error>> => {
const hasBody = params?.method && params?.body && ![ 'GET', 'HEAD' ].includes(params.method);
const hasBody = params?.method && ![ 'GET', 'HEAD' ].includes(params.method);
const reqParams = {
...params,
......
......@@ -48,6 +48,16 @@ export interface AddressTokenBalance {
value: string;
}
export interface AddressTokensResponse {
items: Array<AddressTokenBalance>;
next_page_params: {
items_count: number;
token_name: 'string' | null;
token_type: TokenType;
value: number;
} | null;
}
export interface AddressTransactionsResponse {
items: Array<Transaction>;
next_page_params: {
......@@ -75,6 +85,10 @@ export type AddressTokenTransferFilters = {
type: Array<TokenType>;
}
export type AddressTokensFilter = {
type: TokenType;
}
export interface AddressCoinBalanceHistoryItem {
block_number: number;
block_timestamp: string;
......
......@@ -19,28 +19,36 @@ export type GasPrices = {
slow: number;
}
export type Stats = {
counters: {
totalBlocks: string;
averageBlockTime: string;
totalTransactions: string;
completedTransactions: string;
export type Counters = {
counters: Array<Counter>;
}
totalAccounts: string;
type Counter = {
id: string;
value: string;
title: string;
units: string;
}
totalTokens: string;
export type StatsCharts = {
sections: Array<StatsChartsSection>;
}
totalNativeCoinHolders: string;
totalNativeCoinTransfers: string;
};
export type StatsChartsSection = {
id: string;
title: string;
charts: Array<StatsChartInfo>;
}
export type Charts = {
chart: Array<ChartsItem>;
export type StatsChartInfo = {
id: string;
title: string;
description: string;
}
export type ChartsItem ={
export type StatsChart = { chart: Array<StatsChartItem> };
export type StatsChartItem = {
date: string;
value: string;
}
export type StatsSection = { id: StatsSectionIds; title: string; charts: Array<StatsChart> }
export type StatsSectionIds = keyof typeof StatsSectionId;
export enum StatsSectionId {
'all',
'accounts',
'blocks',
'tokens',
'transactions',
'gas',
}
export type StatsInterval = { id: StatsIntervalIds; title: string }
export type StatsIntervalIds = keyof typeof StatsIntervalId;
export enum StatsIntervalId {
......@@ -18,9 +7,3 @@ export enum StatsIntervalId {
'sixMonths',
'oneYear',
}
export type StatsChart = {
apiId: string;
title: string;
description: string;
}
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { withName } from 'mocks/address/address';
import * as tokenBalanceMock from 'mocks/address/tokenBalance';
import { baseList } from 'mocks/address/tokenBalance';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressTokens from './AddressTokens';
const ADDRESS_HASH = withName.hash;
const API_URL_ADDRESS = buildApiUrl('address', { id: ADDRESS_HASH });
const API_URL_ADDRESS_TOKEN_BALANCES = buildApiUrl('address_token_balances', { id: ADDRESS_HASH });
const API_URL_TOKENS = buildApiUrl('address_tokens', { id: ADDRESS_HASH });
const nextPageParams = {
items_count: 50,
token_name: 'aaa',
token_type: '123',
value: 1,
};
test('erc20 +@mobile +@dark-mode', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { id: ADDRESS_HASH, tab: 'tokens_erc20' },
isReady: true,
},
};
const response20 = {
items: [ tokenBalanceMock.erc20a, tokenBalanceMock.erc20b, tokenBalanceMock.erc20c, tokenBalanceMock.erc20d ],
next_page_params: nextPageParams,
};
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withName),
}));
await page.route(API_URL_ADDRESS_TOKEN_BALANCES, (route) => route.fulfill({
status: 200,
body: JSON.stringify(baseList),
}));
await page.route(API_URL_TOKENS + '?type=ERC-20', (route) => route.fulfill({
status: 200,
body: JSON.stringify(response20),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTokens/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('erc721 +@mobile +@dark-mode', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { id: ADDRESS_HASH, tab: 'tokens_erc721' },
isReady: true,
},
};
const response20 = {
items: [ tokenBalanceMock.erc721a, tokenBalanceMock.erc721b, tokenBalanceMock.erc721c ],
next_page_params: nextPageParams,
};
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withName),
}));
await page.route(API_URL_ADDRESS_TOKEN_BALANCES, (route) => route.fulfill({
status: 200,
body: JSON.stringify(baseList),
}));
await page.route(API_URL_TOKENS + '?type=ERC-721', (route) => route.fulfill({
status: 200,
body: JSON.stringify(response20),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTokens/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('erc1155 +@mobile +@dark-mode', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { id: ADDRESS_HASH, tab: 'tokens_erc1155' },
isReady: true,
},
};
const response20 = {
items: [ tokenBalanceMock.erc1155a, tokenBalanceMock.erc1155b ],
next_page_params: nextPageParams,
};
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withName),
}));
await page.route(API_URL_ADDRESS_TOKEN_BALANCES, (route) => route.fulfill({
status: 200,
body: JSON.stringify(baseList),
}));
await page.route(API_URL_TOKENS + '?type=ERC-1155', (route) => route.fulfill({
status: 200,
body: JSON.stringify(response20),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTokens/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { TokenType } from 'types/api/tokenInfo';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { tokenTabsByType } from 'ui/pages/Address';
import Pagination from 'ui/shared/Pagination';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TokenBalances from './tokens/TokenBalances';
import TokensWithIds from './tokens/TokensWithIds';
import TokensWithoutIds from './tokens/TokensWithoutIds';
const TAB_LIST_PROPS = {
marginBottom: 0,
py: 5,
marginTop: 3,
columnGap: 3,
};
const TAB_LIST_PROPS_MOBILE = {
mt: 8,
columnGap: 3,
};
const AddressTokens = () => {
const router = useRouter();
const isMobile = useIsMobile();
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenType: TokenType = (Object.keys(tokenTabsByType) as Array<TokenType>).find(key => tokenTabsByType[key] === router.query.tab) || 'ERC-20';
const tokensQuery = useQueryWithPages({
resourceName: 'address_tokens',
pathParams: { id: router.query.id?.toString() },
filters: { type: tokenType },
scrollRef,
});
const tabs = [
{ id: tokenTabsByType['ERC-20'], title: 'ERC-20', component: <TokensWithoutIds tokensQuery={ tokensQuery }/> },
{ id: tokenTabsByType['ERC-721'], title: 'ERC-721', component: <TokensWithoutIds tokensQuery={ tokensQuery }/> },
{ id: tokenTabsByType['ERC-1155'], title: 'ERC-1155', component: <TokensWithIds tokensQuery={ tokensQuery }/> },
];
return (
<>
<TokenBalances/>
{ /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ scrollRef }></Box>
<RoutedTabs
tabs={ tabs }
variant="outline"
colorScheme="gray"
size="sm"
tabListProps={ isMobile ? TAB_LIST_PROPS_MOBILE : TAB_LIST_PROPS }
rightSlot={ tokensQuery.isPaginationVisible && !isMobile ? <Pagination { ...tokensQuery.pagination }/> : null }
stickyEnabled={ !isMobile }
/>
</>
);
};
export default AddressTokens;
import { Box, Button, Icon, Skeleton, Text, useColorModeValue } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import arrowIcon from 'icons/arrows/east-mini.svg';
import tokensIcon from 'icons/tokens.svg';
import { ZERO } from 'lib/consts';
import type { EnhancedData } from './utils';
import type { EnhancedData } from '../utils/tokenUtils';
import { getTokenBalanceTotal } from '../utils/tokenUtils';
interface Props {
isOpen: boolean;
......@@ -16,7 +15,7 @@ interface Props {
}
const TokenSelectButton = ({ isOpen, isLoading, onClick, data }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
const totalBn = data.reduce((result, item) => !item.usd ? result : result.plus(BigNumber(item.usd)), ZERO);
const totalBn = getTokenBalanceTotal(data);
const skeletonBgColor = useColorModeValue('white', 'black');
const handleClick = React.useCallback(() => {
......
......@@ -6,7 +6,7 @@ import link from 'lib/link/link';
import HashStringShorten from 'ui/shared/HashStringShorten';
import TokenLogo from 'ui/shared/TokenLogo';
import type { EnhancedData } from './utils';
import type { EnhancedData } from '../utils/tokenUtils';
interface Props {
data: EnhancedData;
......
......@@ -8,9 +8,9 @@ import type { TokenType } from 'types/api/tokenInfo';
import arrowIcon from 'icons/arrows/east.svg';
import searchIcon from 'icons/search.svg';
import type { Sort, EnhancedData } from '../utils/tokenUtils';
import { sortTokenGroups, sortingFns } from '../utils/tokenUtils';
import TokenSelectItem from './TokenSelectItem';
import type { Sort, EnhancedData } from './utils';
import { sortTokenGroups, sortingFns } from './utils';
interface Props {
searchTerm: string;
......
......@@ -4,8 +4,8 @@ import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import type { Sort } from './utils';
import { calculateUsdValue, filterTokens } from './utils';
import type { Sort } from '../utils/tokenUtils';
import { calculateUsdValue, filterTokens } from '../utils/tokenUtils';
export default function useData(data: Array<AddressTokenBalance>) {
const [ searchTerm, setSearchTerm ] = React.useState('');
......
import { Center, Flex, Icon, Link, Text, LinkBox, LinkOverlay, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import NFTIcon from 'icons/nft_shield.svg';
import link from 'lib/link/link';
import TokenLogo from 'ui/shared/TokenLogo';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
type Props = AddressTokenBalance;
const NFTItem = ({ token, token_id: tokenId }: Props) => {
const tokenLink = link('token_index', { hash: token.address });
return (
<LinkBox
w={{ base: 'calc((100% - 12px)/2)', lg: '210px' }}
h={{ base: 'auto', lg: '272px' }}
border="1px solid"
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
borderRadius="12px"
p="10px"
_hover={{ boxShadow: 'md' }}
fontSize="sm"
fontWeight={ 500 }
lineHeight="20px"
>
<LinkOverlay href={ tokenLink }/>
<Center
w={{ base: '100%', lg: '182px' }}
h={{ base: 'calc((100vw - 36px)/2 - 12px)', lg: '182px' }}
bg={ useColorModeValue('blackAlpha.50', 'whiteAlpha.50') }
mb="18px"
borderRadius="12px"
>
<Icon as={ NFTIcon } boxSize="112px" color={ useColorModeValue('blackAlpha.500', 'whiteAlpha.500') }/>
</Center>
{ tokenId && (
<Flex mb={ 2 } ml={ 1 }>
<Text whiteSpace="pre" variant="secondary">ID# </Text>
<TruncatedTextTooltip label={ tokenId }>
<Link
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
href={ link('token_instance_item', { hash: token.address, id: tokenId }) }
>
{ tokenId }
</Link>
</TruncatedTextTooltip>
</Flex>
) }
{ token.name && (
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } ml={ 1 } mr={ 1 }/>
<TruncatedTextTooltip label={ token.name }>
<Text variant="secondary" overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis">{ token.name }</Text>
</TruncatedTextTooltip>
</Flex>
) }
</LinkBox>
);
};
export default NFTItem;
import { Flex, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import { useRouter } from 'next/router';
import React from 'react';
import appConfig from 'configs/app/config';
import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import { getTokenBalanceTotal, calculateUsdValue } from '../utils/tokenUtils';
import TokenBalancesItem from './TokenBalancesItem';
const TokenBalances = () => {
const router = useRouter();
const addressQuery = useApiQuery('address', {
pathParams: { id: router.query.id?.toString() },
queryOptions: { enabled: Boolean(router.query.id) },
});
const balancesQuery = useApiQuery('address_token_balances', {
pathParams: { id: addressQuery.data?.hash },
queryOptions: { enabled: Boolean(addressQuery.data) },
});
if (addressQuery.isError || balancesQuery.isError) {
return <DataFetchAlert/>;
}
if (addressQuery.isLoading || balancesQuery.isLoading) {
const item = <Skeleton w={{ base: '100%', lg: '240px' }} h="82px" borderRadius="16px"/>;
return (
<Flex columnGap={ 3 } rowGap={ 3 } mt={{ base: '6px', lg: 0 }} flexDirection={{ base: 'column', lg: 'row' }}>
{ item }
{ item }
{ item }
</Flex>
);
}
const addressData = addressQuery.data;
const { valueStr: nativeValue, usd: nativeUsd } = getCurrencyValue({
value: addressData.coin_balance || '0',
accuracy: 8,
accuracyUsd: 2,
exchangeRate: addressData.exchange_rate,
decimals: String(appConfig.network.currency.decimals),
});
const tokenBalanceBn = getTokenBalanceTotal(balancesQuery.data.map(calculateUsdValue)).toFixed(2);
const totalUsd = nativeUsd ? BigNumber(nativeUsd).toNumber() + BigNumber(tokenBalanceBn).toNumber() : undefined;
return (
<Flex columnGap={ 3 } rowGap={ 3 } mt={{ base: '6px', lg: 0 }} flexDirection={{ base: 'column', lg: 'row' }}>
<TokenBalancesItem name="Net Worth" value={ totalUsd ? `$${ totalUsd } USD` : 'N/A' }/>
<TokenBalancesItem
name={ `${ appConfig.network.currency.symbol } Balance` }
value={ (nativeUsd ? `$${ nativeUsd } USD | ` : '') + `${ nativeValue } ${ appConfig.network.currency.symbol }` }
/>
<TokenBalancesItem
name="Tokens"
value={
`$${ tokenBalanceBn } USD ` +
(balancesQuery.data.length ? ` | ${ balancesQuery.data.length } ${ balancesQuery.data.length === 1 ? 'token' : 'tokens' }` : '')
}
/>
</Flex>
);
};
export default React.memo(TokenBalances);
import { Box, Flex, Icon, Text, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import walletIcon from 'icons/wallet.svg';
const TokenBalancesItem = ({ name, value }: {name: string; value: string }) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
return (
<Flex p={ 5 } bgColor={ bgColor } borderRadius="16px" alignItems="center">
<Icon as={ walletIcon } boxSize="30px" mr={ 3 }/>
<Box>
<Text variant="secondary" fontSize="xs">{ name }</Text>
<Text fontWeight="500">{ value }</Text>
</Box>
</Flex>
);
};
export default React.memo(TokenBalancesItem);
import { Flex, HStack, Text } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import getCurrencyValue from 'lib/getCurrencyValue';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import ListItemMobile from 'ui/shared/ListItemMobile';
import TokenLogo from 'ui/shared/TokenLogo';
import AddressAddToMetaMask from '../details/AddressAddToMetaMask';
type Props = AddressTokenBalance;
const TokensListItem = ({ token, value }: Props) => {
const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' ');
const {
valueStr: tokenQuantity,
usd: tokenValue,
} = getCurrencyValue({ value: value, exchangeRate: token.exchange_rate, decimals: token.decimals, accuracy: 8, accuracyUsd: 2 });
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" pl={ 8 }>
<AddressLink hash={ token.address } type="address" truncation="constant"/>
<CopyToClipboard text={ token.address } ml={ 1 }/>
<AddressAddToMetaMask token={ token } ml={ 2 }/>
</Flex>
{ token.exchange_rate !== undefined && token.exchange_rate !== null && (
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Price</Text>
<Text fontSize="sm" variant="secondary">{ `$${ token.exchange_rate }` }</Text>
</HStack>
) }
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Quantity</Text>
<Text fontSize="sm" variant="secondary">{ tokenQuantity }</Text>
</HStack>
{ tokenValue !== undefined && (
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Value</Text>
<Text fontSize="sm" variant="secondary">{ tokenValue }</Text>
</HStack>
) }
</ListItemMobile>
);
};
export default TokensListItem;
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import { default as Thead } from 'ui/shared/TheadSticky';
import TokensTableItem from './TokensTableItem';
interface Props {
data: Array<AddressTokenBalance>;
top: number;
}
const TokensTable = ({ data, top }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
<Th width="30%">Asset</Th>
<Th width="30%">Contract address</Th>
<Th width="10%" isNumeric>Price</Th>
<Th width="15%" isNumeric>Quantity</Th>
<Th width="15%" isNumeric>Value</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<TokensTableItem key={ item.token.address } { ...item }/>
)) }
</Tbody>
</Table>
);
};
export default TokensTable;
import { Tr, Td, Flex } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import getCurrencyValue from 'lib/getCurrencyValue';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import TokenLogo from 'ui/shared/TokenLogo';
import AddressAddToMetaMask from '../details/AddressAddToMetaMask';
type Props = AddressTokenBalance;
const TokensTableItem = ({
token,
value,
}: Props) => {
const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' ');
const {
valueStr: tokenQuantity,
usd: tokenValue,
} = getCurrencyValue({ value: value, exchangeRate: token.exchange_rate, decimals: token.decimals, accuracy: 8, accuracyUsd: 2 });
return (
<Tr>
<Td verticalAlign="middle">
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex>
</Td>
<Td verticalAlign="middle">
<Flex alignItems="center" width="150px" justifyContent="space-between">
<Flex alignItems="center">
<AddressLink hash={ token.address } type="address" truncation="constant"/>
<CopyToClipboard text={ token.address } ml={ 1 }/>
</Flex>
<AddressAddToMetaMask token={ token } ml={ 4 }/>
</Flex>
</Td>
<Td isNumeric verticalAlign="middle">
{ token.exchange_rate ? `$${ token.exchange_rate }` : '-' }
</Td>
<Td isNumeric verticalAlign="middle">
{ tokenQuantity }
</Td>
<Td isNumeric verticalAlign="middle">
{ tokenValue ? `$${ tokenValue }` : '-' }
</Td>
</Tr>
);
};
export default React.memo(TokensTableItem);
import { Flex, Skeleton, Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { AddressTokensResponse } from 'types/api/address';
import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
import NFTItem from './NFTItem';
type Props = {
tokensQuery: UseQueryResult<AddressTokensResponse> & {
pagination: PaginationProps;
isPaginationVisible: boolean;
};
}
const TokensWithIds = ({ tokensQuery }: Props) => {
const isMobile = useIsMobile();
const { isError, isLoading, data, pagination, isPaginationVisible } = tokensQuery;
if (isError) {
return <DataFetchAlert/>;
}
const bar = isMobile && isPaginationVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
if (isLoading) {
return (
<>
{ bar }
<Flex columnGap={ 6 } rowGap={ 6 } flexWrap="wrap">
<Skeleton w="210px" h="272px"/>
<Skeleton w="210px" h="272px"/>
<Skeleton w="210px" h="272px"/>
</Flex>
</>
);
}
if (!data.items.length) {
return <Text as="span">There are no tokens of selected type.</Text>;
}
return (
<>
{ bar }
<Flex columnGap={{ base: 3, lg: 6 }} rowGap={{ base: 3, lg: 6 }} flexWrap="wrap">
{ data.items.map(item => <NFTItem key={ item.token.address } { ...item }/>) }
</Flex>
</>
);
};
export default TokensWithIds;
import { Text, Show, Hide } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { AddressTokensResponse } from 'types/api/address';
import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import TokensListItem from './TokensListItem';
import TokensTable from './TokensTable';
type Props = {
tokensQuery: UseQueryResult<AddressTokensResponse> & {
pagination: PaginationProps;
isPaginationVisible: boolean;
};
}
const TokensWithoutIds = ({ tokensQuery }: Props) => {
const isMobile = useIsMobile();
const { isError, isLoading, data, pagination, isPaginationVisible } = tokensQuery;
if (isError) {
return <DataFetchAlert/>;
}
const bar = isMobile && isPaginationVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
if (isLoading) {
return (
<>
{ bar }
<Hide below="lg" ssr={ false }><SkeletonTable columns={ [ '30%', '30%', '10%', '20%', '10%' ] }/></Hide>
<Show below="lg" ssr={ false }><SkeletonList/></Show>
</>
);
}
if (data.items.length === 0) {
return <Text as="span">There are no tokens of selected type.</Text>;
}
return (
<>
{ bar }
<Hide below="lg" ssr={ false }><TokensTable data={ data.items } top={ isPaginationVisible ? 72 : 0 }/></Hide>
<Show below="lg" ssr={ false }>{ data.items.map(item => <TokensListItem key={ item.token.address } { ...item }/>) }</Show>
</>
);
};
export default TokensWithoutIds;
......@@ -3,6 +3,8 @@ import BigNumber from 'bignumber.js';
import type { AddressTokenBalance } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo';
import { ZERO } from 'lib/consts';
export type EnhancedData = AddressTokenBalance & {
usd?: BigNumber ;
}
......@@ -77,3 +79,7 @@ export const calculateUsdValue = (data: AddressTokenBalance): EnhancedData => {
usd: BigNumber(data.value).div(BigNumber(10 ** decimals)).multipliedBy(BigNumber(exchangeRate)),
};
};
export const getTokenBalanceTotal = (data: Array<EnhancedData>) => {
return data.reduce((result, item) => !item.usd ? result : result.plus(BigNumber(item.usd)), ZERO);
};
......@@ -27,7 +27,6 @@ test('regular block +@mobile +@dark-mode', async({ mount, page }) => {
{ hooksConfig },
);
await page.waitForResponse(API_URL),
await page.getByText('View details').click();
await expect(component).toHaveScreenshot();
......@@ -46,7 +45,6 @@ test('genesis block', async({ mount, page }) => {
{ hooksConfig },
);
await page.waitForResponse(API_URL),
await page.getByText('View details').click();
await expect(component).toHaveScreenshot();
......
......@@ -31,7 +31,7 @@ const BlockDetails = () => {
const [ isExpanded, setIsExpanded ] = React.useState(false);
const router = useRouter();
const { data, isLoading, isError, error } = useApiQuery<'block', { status: number }>('block', {
const { data, isLoading, isError, error } = useApiQuery('block', {
pathParams: { id: router.query.id?.toString() },
queryOptions: { enabled: Boolean(router.query.id) },
});
......@@ -59,11 +59,11 @@ const BlockDetails = () => {
}
if (isError) {
if (error?.payload?.status === 404) {
return <span>This block has not been processed yet.</span>;
if (error?.status === 404) {
throw Error('Block not found', { cause: error as unknown as Error });
}
if (error?.payload?.status === 422) {
if (error?.status === 422) {
throw Error('Invalid block number', { cause: error as unknown as Error });
}
......
......@@ -2,6 +2,7 @@ import { Flex, Skeleton, Tag, Box, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { TokenType } from 'types/api/tokenInfo';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import iconSuccess from 'icons/status/success.svg';
......@@ -13,6 +14,7 @@ import AddressContract from 'ui/address/AddressContract';
import AddressDetails from 'ui/address/AddressDetails';
import AddressInternalTxs from 'ui/address/AddressInternalTxs';
import AddressLogs from 'ui/address/AddressLogs';
import AddressTokens from 'ui/address/AddressTokens';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs';
import ContractCode from 'ui/address/contract/ContractCode';
......@@ -24,6 +26,14 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
export const tokenTabsByType: Record<TokenType, string> = {
'ERC-20': 'tokens_erc20',
'ERC-721': 'tokens_erc721',
'ERC-1155': 'tokens_erc1155',
} as const;
const TOKEN_TABS = Object.values(tokenTabsByType);
const AddressPageContent = () => {
const router = useRouter();
......@@ -73,7 +83,7 @@ const AddressPageContent = () => {
addressQuery.data?.has_token_transfers ?
{ id: 'token_transfers', title: 'Token transfers', component: <AddressTokenTransfers scrollRef={ tabsScrollRef }/> } :
undefined,
addressQuery.data?.has_tokens ? { id: 'tokens', title: 'Tokens', component: null } : undefined,
addressQuery.data?.has_tokens ? { id: 'tokens', title: 'Tokens', component: <AddressTokens/>, subTabs: TOKEN_TABS } : undefined,
{ id: 'internal_txns', title: 'Internal txns', component: <AddressInternalTxs scrollRef={ tabsScrollRef }/> },
{ id: 'coin_balance_history', title: 'Coin balance history', component: <AddressCoinBalance/> },
addressQuery.data?.has_validated_blocks ?
......@@ -95,7 +105,7 @@ const AddressPageContent = () => {
return 'Contract';
},
component: <AddressContract tabs={ contractTabs }/>,
subTabs: contractTabs,
subTabs: contractTabs.map(tab => tab.id),
} : undefined,
].filter(notEmpty);
}, [ addressQuery.data, contractTabs ]);
......
......@@ -12,12 +12,16 @@ import useStats from '../stats/useStats';
const Stats = () => {
const {
section,
isLoading,
isError,
sections,
currentSection,
handleSectionChange,
interval,
handleIntervalChange,
debounceFilterCharts,
displayedCharts,
filterQuery,
} = useStats();
return (
......@@ -30,7 +34,8 @@ const Stats = () => {
<Box mb={{ base: 6, sm: 8 }}>
<StatsFilters
section={ section }
sections={ sections }
currentSection={ currentSection }
onSectionChange={ handleSectionChange }
interval={ interval }
onIntervalChange={ handleIntervalChange }
......@@ -39,6 +44,9 @@ const Stats = () => {
</Box>
<ChartsWidgetsList
filterQuery={ filterQuery }
isError={ isError }
isLoading={ isLoading }
charts={ displayedCharts }
interval={ interval }
/>
......
......@@ -52,7 +52,7 @@ const Page = ({
const renderErrorScreen = React.useCallback((error?: Error) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const statusCode = (error?.cause as any)?.error?.status || 500;
const statusCode = (error?.cause as any)?.status || 500;
const isInvalidTxHash = error?.message.includes('Invalid tx hash');
if (wrapChildren) {
......
......@@ -62,7 +62,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
let tabIndex = 0;
const tabFromRoute = router.query.tab;
if (tabFromRoute) {
tabIndex = tabs.findIndex(({ id, subTabs }) => id === tabFromRoute || subTabs?.some(({ id }) => id === tabFromRoute));
tabIndex = tabs.findIndex(({ id, subTabs }) => id === tabFromRoute || subTabs?.some((id) => id === tabFromRoute));
if (tabIndex < 0) {
tabIndex = 0;
}
......
......@@ -4,7 +4,7 @@ export interface RoutedTab {
id: string;
title: string | (() => React.ReactNode);
component: React.ReactNode;
subTabs?: Array<RoutedSubTab>;
subTabs?: Array<string>;
}
export type RoutedSubTab = Omit<RoutedTab, 'subTabs'>;
......
......@@ -236,9 +236,10 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight, isErro
<Text
variant="secondary"
fontSize="sm"
textAlign="center"
>
{ `Data didn${ apos }t load, please ` }
<Link href={ window.document.location.href }>try to reload page.</Link>
{ `The data didn${ apos }t load. Please, ` }
<Link href={ window.document.location.href }>try to reload the page.</Link>
</Text>
</Flex>
) }
......
......@@ -26,8 +26,8 @@ export default function useChartSize(svgEl: SVGSVGElement | null, margin?: Chart
setRect({ width: 0, height: 0 });
timeoutId = window.setTimeout(() => {
setRect(calculateRect());
}, 0);
}, 200);
}, 100);
}, 100);
const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(content);
......
......@@ -25,18 +25,17 @@ const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError
const endDate = selectedInterval.start ? formatDate(new Date()) : undefined;
const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined;
const { data, isLoading, isError } = useApiQuery('stats_charts', {
const { data, isLoading, isError } = useApiQuery('stats_line', {
pathParams: { id },
queryParams: {
name: id,
from: startDate,
to: endDate,
},
});
const items = data?.chart
.map((item) => {
return { date: new Date(item.date), value: Number(item.value) };
});
const items = data?.chart?.map((item) => {
return { date: new Date(item.date), value: Number(item.value) };
});
useEffect(() => {
if (isError) {
......
import { Box, Grid, GridItem, Heading, List, ListItem } from '@chakra-ui/react';
import { Box, Grid, GridItem, Heading, List, ListItem, Skeleton } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import type { StatsIntervalIds, StatsSection } from 'types/client/stats';
import type { StatsChartsSection } from 'types/api/stats';
import type { StatsIntervalIds } from 'types/client/stats';
import { apos } from 'lib/html-entities';
import EmptySearchResult from '../apps/EmptySearchResult';
import ChartWidgetSkeleton from '../shared/chart/ChartWidgetSkeleton';
import ChartsLoadingErrorAlert from './ChartsLoadingErrorAlert';
import ChartWidgetContainer from './ChartWidgetContainer';
type Props = {
charts: Array<StatsSection>;
filterQuery: string;
isError: boolean;
isLoading: boolean;
charts?: Array<StatsChartsSection>;
interval: StatsIntervalIds;
}
const ChartsWidgetsList = ({ charts, interval }: Props) => {
const skeletonsCount = 4;
const ChartsWidgetsList = ({ filterQuery, isError, isLoading, charts, interval }: Props) => {
const [ isSomeChartLoadingError, setIsSomeChartLoadingError ] = useState(false);
const isAnyChartDisplayed = charts.some((section) => section.charts.length > 0);
const isAnyChartDisplayed = charts?.some((section) => section.charts.length > 0);
const isEmptyChartList = Boolean(filterQuery) && !isAnyChartDisplayed;
const handleChartLoadingError = useCallback(
() => setIsSomeChartLoadingError(true),
[ setIsSomeChartLoadingError ]);
const skeletonElement = [ ...Array(skeletonsCount) ]
.map((e, i) => (
<GridItem key={ i }>
<ChartWidgetSkeleton hasDescription={ true }/>
</GridItem>
));
if (isLoading) {
return (
<>
<Skeleton w="30%" h="32px" mb={ 4 }/>
<Grid
templateColumns={{
lg: 'repeat(2, minmax(0, 1fr))',
}}
gap={ 4 }
>
{ skeletonElement }
</Grid>
</>
);
}
if (isError) {
return <ChartsLoadingErrorAlert/>;
}
if (isEmptyChartList) {
return <EmptySearchResult text={ `Couldn${ apos }t find a chart that matches your filter query.` }/>;
}
return (
<Box>
{ isSomeChartLoadingError && (
<ChartsLoadingErrorAlert/>
) }
{ isAnyChartDisplayed ? (
<List>
{
charts.map((section) => (
<ListItem
key={ section.id }
mb={ 8 }
_last={{
marginBottom: 0,
}}
<List>
{
charts?.map((section) => (
<ListItem
key={ section.id }
mb={ 8 }
_last={{
marginBottom: 0,
}}
>
<Heading
size="md"
mb={ 4 }
>
<Heading
size="md"
mb={ 4 }
>
{ section.title }
</Heading>
{ section.title }
</Heading>
<Grid
templateColumns={{
lg: 'repeat(2, minmax(0, 1fr))',
}}
gap={ 4 }
>
{ section.charts.map((chart) => (
<GridItem
key={ chart.apiId }
>
<ChartWidgetContainer
id={ chart.apiId }
title={ chart.title }
description={ chart.description }
interval={ interval }
onLoadingError={ handleChartLoadingError }
/>
</GridItem>
)) }
</Grid>
</ListItem>
))
}
</List>
) : (
<EmptySearchResult text={ `Couldn${ apos }t find a chart that matches your filter query.` }/>
) }
<Grid
templateColumns={{
lg: 'repeat(2, minmax(0, 1fr))',
}}
gap={ 4 }
>
{ section.charts.map((chart) => (
<GridItem
key={ chart.id }
>
<ChartWidgetContainer
id={ chart.id }
title={ chart.title }
description={ chart.description }
interval={ interval }
onLoadingError={ handleChartLoadingError }
/>
</GridItem>
)) }
</Grid>
</ListItem>
))
}
</List>
</Box>
);
};
......
......@@ -4,38 +4,35 @@ import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import formatNumberToMetricPrefix from 'lib/formatNumberToMetricPrefix';
import { numberWidgetsScheme } from './constants/number-widgets-scheme';
import DataFetchAlert from '../shared/DataFetchAlert';
import NumberWidget from './NumberWidget';
import NumberWidgetSkeleton from './NumberWidgetSkeleton';
const skeletonsCount = 8;
const NumberWidgetsList = () => {
const { data, isLoading } = useApiQuery('stats_counters');
const { data, isLoading, isError } = useApiQuery('stats_counters');
const skeletonElement = [ ...Array(skeletonsCount) ]
.map((e, i) => <NumberWidgetSkeleton key={ i }/>);
if (isError) {
return <DataFetchAlert/>;
}
return (
<Grid
gridTemplateColumns={{ base: 'repeat(2, 1fr)', lg: 'repeat(4, 1fr)' }}
gridGap={ 4 }
>
{ isLoading ? skeletonElement :
numberWidgetsScheme.map(({ id, title, formatFn }) => {
if (!data?.counters[id]) {
return null;
}
const value = formatNumberToMetricPrefix(Number(data.counters[id]));
data?.counters?.map(({ id, title, value, units }) => {
return (
<NumberWidget
key={ id }
label={ title }
value={ formatFn ?
formatFn(value) :
value }
value={ `${ formatNumberToMetricPrefix(Number(value)) } ${ units ? units : '' }` }
/>
);
}) }
......
import { Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import type { StatsInterval, StatsIntervalIds, StatsSection, StatsSectionIds } from 'types/client/stats';
import type { StatsChartsSection } from 'types/api/stats';
import type { StatsInterval, StatsIntervalIds } from 'types/client/stats';
import FilterInput from 'ui/shared/FilterInput';
import { STATS_INTERVALS, STATS_SECTIONS } from './constants';
import { statsChartsScheme } from './constants/charts-scheme';
import { STATS_INTERVALS } from './constants';
import StatsDropdownMenu from './StatsDropdownMenu';
const listedSections = statsChartsScheme
.filter(section => section.charts.length > 0);
const sectionsList = Object.keys(STATS_SECTIONS)
.filter(key => key === 'all' || listedSections.some(section => section.id === key))
.map((id: string) => ({
id: id,
title: STATS_SECTIONS[id as StatsSectionIds],
})) as Array<StatsSection>;
const intervalList = Object.keys(STATS_INTERVALS).map((id: string) => ({
id: id,
title: STATS_INTERVALS[id as StatsIntervalIds].title,
})) as Array<StatsInterval>;
type Props = {
section: StatsSectionIds;
onSectionChange: (newSection: StatsSectionIds) => void;
sections?: Array<StatsChartsSection>;
currentSection: string;
onSectionChange: (newSection: string) => void;
interval: StatsIntervalIds;
onIntervalChange: (newInterval: StatsIntervalIds) => void;
onFilterInputChange: (q: string) => void;
}
const StatsFilters = ({
section,
sections,
currentSection,
onSectionChange,
interval,
onIntervalChange,
onFilterInputChange,
}: Props) => {
const sectionsList = [ {
id: 'all',
title: 'All',
}, ... (sections || []) ];
return (
<Grid
gap={ 2 }
......@@ -56,7 +53,7 @@ const StatsFilters = ({
>
<StatsDropdownMenu
items={ sectionsList }
selectedId={ section }
selectedId={ currentSection }
onSelect={ onSectionChange }
/>
</GridItem>
......
import type { StatsSection } from 'types/client/stats';
export const statsChartsScheme: Array<StatsSection> = [
{
id: 'accounts',
title: 'Accounts',
charts: [
{
apiId: 'activeAccounts',
title: 'Active accounts',
description: 'Active accounts number per period',
},
{
apiId: 'accountsGrowth',
title: 'Accounts growth',
description: 'Cumulative accounts number per period',
},
],
},
{
id: 'transactions',
title: 'Transactions',
charts: [
{
apiId: 'averageTxnFee',
title: 'Average transaction fee',
description: 'The average amount in USD spent per transaction',
},
{
apiId: 'txnsFee',
title: 'Transactions fees',
description: 'Amount of tokens paid as fees',
},
{
apiId: 'newTxns',
title: 'New transactions',
description: 'New transactions number',
},
{
apiId: 'txnsGrowth',
title: 'Transactions growth',
description: 'Cumulative transactions number',
},
],
},
{
id: 'blocks',
title: 'Blocks',
charts: [
{
apiId: 'newBlocks',
title: 'New blocks',
description: 'New blocks number',
},
{
apiId: 'averageBlockSize',
title: 'Average block size',
description: 'Average size of blocks in bytes',
},
],
},
{
id: 'tokens',
title: 'Tokens',
charts: [
{
apiId: 'nativeCoinHoldersGrowth',
title: 'Native coin holders growth',
description: 'Cumulative token holders number for the period',
},
{
apiId: 'newNativeCoinTransfers',
title: 'New native coins transfers',
description: 'New token transfers number for the period',
},
{
apiId: 'nativeCoinSupply',
title: 'Native coin circulating supply',
description: 'Amount of token circulating supply for the period',
},
],
},
{
id: 'gas',
title: 'Gas',
charts: [
{
apiId: 'averageGasLimit',
title: 'Average gas limit',
description: 'Average gas limit per block for the period',
},
{
apiId: 'gasUsedGrowth',
title: 'Gas used growth',
description: 'Cumulative gas used for the period',
},
{
apiId: 'averageGasPrice',
title: 'Average gas price',
description: 'Average gas price for the period (Gwei)',
},
],
},
];
import type { StatsSectionIds, StatsIntervalIds } from 'types/client/stats';
export const STATS_SECTIONS: { [key in StatsSectionIds]?: string } = {
all: 'All stats',
accounts: 'Accounts',
blocks: 'Blocks',
transactions: 'Transactions',
gas: 'Gas',
};
import type { StatsIntervalIds } from 'types/client/stats';
export const STATS_INTERVALS: { [key in StatsIntervalIds]: { title: string; start?: Date } } = {
all: {
......
import type { Stats } from 'types/api/stats';
type Key = keyof Stats['counters'];
export const numberWidgetsScheme: Array<{id: Key; title: string; formatFn?: (n: string) => string}> = [
{
id: 'totalBlocks',
title: 'Total blocks',
},
{
id: 'averageBlockTime',
title: 'Average block time',
formatFn: (n) => `${ n } s`,
},
{
id: 'totalTransactions',
title: 'Total transactions',
},
{
id: 'completedTransactions',
title: 'Completed transactions',
},
{
id: 'totalAccounts',
title: 'Total accounts',
},
{
id: 'totalTokens',
title: 'Total tokens',
},
{
id: 'totalNativeCoinHolders',
title: 'Total native coin holders',
},
{
id: 'totalNativeCoinTransfers',
title: 'Total native coin transfers',
},
];
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { StatsChart, StatsIntervalIds, StatsSection, StatsSectionIds } from 'types/client/stats';
import type { StatsChartInfo, StatsChartsSection } from 'types/api/stats';
import type { StatsIntervalIds } from 'types/client/stats';
import { statsChartsScheme } from './constants/charts-scheme';
import useApiQuery from 'lib/api/useApiQuery';
function isSectionMatches(section: StatsSection, currentSection: StatsSectionIds): boolean {
function isSectionMatches(section: StatsChartsSection, currentSection: string): boolean {
return currentSection === 'all' || section.id === currentSection;
}
function isChartNameMatches(q: string, chart: StatsChart) {
function isChartNameMatches(q: string, chart: StatsChartInfo) {
return chart.title.toLowerCase().includes(q.toLowerCase());
}
export default function useStats() {
const [ displayedCharts, setDisplayedCharts ] = useState<Array<StatsSection>>(statsChartsScheme);
const [ section, setSection ] = useState<StatsSectionIds>('all');
const [ interval, setInterval ] = useState<StatsIntervalIds>('oneMonth');
const { data, isLoading, isError } = useApiQuery('stats_lines');
const [ currentSection, setCurrentSection ] = useState('all');
const [ filterQuery, setFilterQuery ] = useState('');
const [ displayedCharts, setDisplayedCharts ] = useState(data?.sections);
const [ interval, setInterval ] = useState<StatsIntervalIds>('oneMonth');
const sectionIds = useMemo(() => data?.sections?.map(({ id }) => id), [ data ]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceFilterCharts = useCallback(debounce(q => setFilterQuery(q), 500), []);
const filterCharts = useCallback((q: string, currentSection: StatsSectionIds) => {
const charts = statsChartsScheme
?.map((section: StatsSection) => {
const charts = section.charts.filter((chart: StatsChart) => isSectionMatches(section, currentSection) && isChartNameMatches(q, chart));
const filterCharts = useCallback((q: string, currentSection: string) => {
const charts = data?.sections
?.map((section) => {
const charts = section.charts.filter((chart) => isSectionMatches(section, currentSection) && isChartNameMatches(q, chart));
return {
...section,
charts,
};
}).filter((section: StatsSection) => section.charts.length > 0);
}).filter((section) => section.charts.length > 0);
setDisplayedCharts(charts || []);
}, []);
}, [ data ]);
const handleSectionChange = useCallback((newSection: StatsSectionIds) => {
setSection(newSection);
const handleSectionChange = useCallback((newSection: string) => {
setCurrentSection(newSection);
}, []);
const handleIntervalChange = useCallback((newInterval: StatsIntervalIds) => {
......@@ -45,18 +49,28 @@ export default function useStats() {
}, []);
useEffect(() => {
filterCharts(filterQuery, section);
}, [ filterQuery, section, filterCharts ]);
filterCharts(filterQuery, currentSection);
}, [ filterQuery, currentSection, filterCharts ]);
return React.useMemo(() => ({
section,
sections: data?.sections,
sectionIds,
isLoading,
isError,
filterQuery,
currentSection,
handleSectionChange,
interval,
handleIntervalChange,
debounceFilterCharts,
displayedCharts,
}), [
section,
data,
sectionIds,
isLoading,
isError,
filterQuery,
currentSection,
handleSectionChange,
interval,
handleIntervalChange,
......
......@@ -68,12 +68,12 @@ const TxDetails = () => {
}
if (isError) {
if (error?.payload?.status === 422) {
if (error?.status === 422) {
throw Error('Invalid tx hash', { cause: error as unknown as Error });
}
if (error?.payload?.status === 404) {
throw Error('Tx fetch failed', { cause: error as unknown as Error });
if (error?.status === 404) {
throw Error('Tx not found', { cause: error as unknown as Error });
}
return <DataFetchAlert/>;
......
......@@ -32,9 +32,9 @@ const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo, curr
<Th width="160px">Type</Th>
<Th width="20%">Method</Th>
{ showBlockInfo && <Th width="18%">Block</Th> }
<Th width={{ xl: '128px', base: '66px' }}>From</Th>
<Th width={{ xl: '132px', base: '66px' }}>From</Th>
<Th width={{ xl: currentAddress ? '48px' : '36px', base: '0' }}></Th>
<Th width={{ xl: '128px', base: '66px' }}>To</Th>
<Th width={{ xl: '132px', base: '66px' }}>To</Th>
<Th width="20%" isNumeric>
<Link onClick={ sort('val') } display="flex" justifyContent="end">
{ sorting === 'val-asc' && <Icon boxSize={ 5 } as={ rightArrowIcon } transform="rotate(-90deg)"/> }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment