Commit a8343da3 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into cors-refactoring

parents d6d7deb4 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.
......@@ -320,18 +320,20 @@ frontend:
- "/login"
- "/address"
- "/stats"
- "/search-results"
- "/token"
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
......@@ -399,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'}}]"
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.038 13.692h6.675l-4.147 4.148.832.832 5.568-5.568-5.568-5.568-.832.832 4.147 4.148H8.038A3.832 3.832 0 0 1 4.21 8.687V1.329H3.033v7.36a5.01 5.01 0 0 0 5.005 5.004Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 201 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M100.027 8.408c-2.187 0-4.238.41-6.152 1.23a15.973 15.973 0 0 0-4.922 3.35c-1.413 1.413-2.53 3.076-3.35 4.99-.82 1.869-1.23 3.897-1.23 6.084a3.372 3.372 0 0 1-3.913 3.328l-2.409-.392a2.974 2.974 0 0 1-2.496-2.936c0-3.326.615-6.448 1.845-9.365 1.276-2.916 3.008-5.468 5.196-7.656 2.187-2.187 4.74-3.896 7.656-5.127C93.169.638 96.29 0 99.617 0h4.17c3.327 0 6.449.592 9.365 1.777 2.963 1.185 5.537 2.894 7.725 5.127 2.187 2.233 3.896 4.922 5.127 8.067 1.276 3.099 1.914 6.608 1.914 10.527 0 3.874-.592 7.77-1.777 11.69a67.002 67.002 0 0 1-4.58 11.552 87.122 87.122 0 0 1-6.495 11.006 147.24 147.24 0 0 1-7.451 10.049c-5.97 7.337-12.737 14.401-20.302 21.191h32.88a8.409 8.409 0 0 1 8.409 8.408H73.436v-2.253c0-2.988 1.345-5.81 3.612-7.756 7.075-6.073 13.482-12.33 19.22-18.77a162.094 162.094 0 0 0 8.408-10.322 100.413 100.413 0 0 0 7.314-11.211c2.142-3.874 3.851-7.793 5.127-11.758 1.322-4.01 1.983-7.952 1.983-11.826 0-2.871-.433-5.378-1.299-7.52-.866-2.141-2.028-3.919-3.487-5.332a13.909 13.909 0 0 0-5.058-3.144 16.03 16.03 0 0 0-5.879-1.094h-3.35ZM48.193 82.441v16.953a8.818 8.818 0 0 1-8.818-8.818v-8.135H3.3a3.3 3.3 0 0 1-2.872-4.926L42.701 2.857a2.937 2.937 0 0 1 5.492 1.447v69.73h8.409a8.408 8.408 0 0 1-8.409 8.407Zm-8.818-56.943L12.373 74.033h27.002V25.498Zm126.5-15.86c1.914-.82 3.965-1.23 6.152-1.23h3.35c2.051 0 4.01.365 5.879 1.094 1.914.683 3.6 1.732 5.058 3.144 1.459 1.413 2.621 3.19 3.487 5.332.866 2.142 1.299 4.649 1.299 7.52 0 3.874-.661 7.816-1.983 11.826-1.276 3.965-2.985 7.884-5.127 11.758a100.413 100.413 0 0 1-7.314 11.211 162.124 162.124 0 0 1-8.408 10.322c-5.738 6.44-12.145 12.697-19.22 18.77-2.267 1.946-3.612 4.768-3.612 7.756v2.253h55.166a8.409 8.409 0 0 0-8.409-8.408h-32.881c7.566-6.79 14.333-13.854 20.303-21.191a147.24 147.24 0 0 0 7.451-10.049 87.122 87.122 0 0 0 6.495-11.006 67.002 67.002 0 0 0 4.58-11.553c1.185-3.919 1.777-7.815 1.777-11.689 0-3.92-.638-7.428-1.914-10.527-1.231-3.145-2.94-5.834-5.127-8.067-2.188-2.233-4.762-3.942-7.725-5.127C182.236.592 179.114 0 175.787 0h-4.17c-3.327 0-6.448.638-9.365 1.914-2.917 1.23-5.469 2.94-7.656 5.127-2.188 2.188-3.92 4.74-5.196 7.656-1.23 2.917-1.845 6.039-1.845 9.366a2.974 2.974 0 0 0 2.496 2.935l2.409.392a3.371 3.371 0 0 0 3.913-3.328c0-2.187.41-4.215 1.231-6.084.82-1.914 1.936-3.577 3.349-4.99a15.982 15.982 0 0 1 4.922-3.35Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M100.027 8.408c-2.187 0-4.238.41-6.152 1.23a15.973 15.973 0 0 0-4.922 3.35c-1.413 1.413-2.53 3.076-3.35 4.99-.82 1.869-1.23 3.897-1.23 6.084a3.372 3.372 0 0 1-3.913 3.328l-2.409-.392a2.974 2.974 0 0 1-2.496-2.936c0-3.326.615-6.448 1.845-9.365 1.276-2.916 3.008-5.468 5.196-7.656 2.187-2.187 4.74-3.896 7.656-5.127C93.169.638 96.29 0 99.617 0h4.17c3.327 0 6.449.592 9.365 1.777 2.963 1.185 5.537 2.894 7.725 5.127 2.187 2.233 3.896 4.922 5.127 8.067 1.276 3.099 1.914 6.608 1.914 10.527 0 3.874-.592 7.77-1.777 11.69a67.002 67.002 0 0 1-4.58 11.552 87.122 87.122 0 0 1-6.495 11.006 147.24 147.24 0 0 1-7.451 10.049c-5.97 7.337-12.737 14.401-20.302 21.191h32.88a8.409 8.409 0 0 1 8.409 8.408H73.436v-2.253c0-2.988 1.345-5.81 3.612-7.756 7.075-6.073 13.482-12.33 19.22-18.77a162.094 162.094 0 0 0 8.408-10.322 100.413 100.413 0 0 0 7.314-11.211c2.142-3.874 3.851-7.793 5.127-11.758 1.322-4.01 1.983-7.952 1.983-11.826 0-2.871-.433-5.378-1.299-7.52-.866-2.141-2.028-3.919-3.487-5.332a13.909 13.909 0 0 0-5.058-3.144 16.03 16.03 0 0 0-5.879-1.094h-3.35ZM48.193 82.441v16.953a8.818 8.818 0 0 1-8.818-8.818v-8.135H3.3a3.3 3.3 0 0 1-2.872-4.926L42.701 2.857a2.937 2.937 0 0 1 5.492 1.447v69.73h8.409a8.408 8.408 0 0 1-8.409 8.407Zm-8.818-56.943L12.373 74.033h27.002V25.498Zm126.5-15.86c1.914-.82 3.965-1.23 6.152-1.23h3.35c2.051 0 4.01.365 5.879 1.094a13.91 13.91 0 0 1 5.058 3.144c1.459 1.413 2.621 3.19 3.487 5.332.866 2.142 1.299 4.649 1.299 7.52 0 3.874-.661 7.816-1.983 11.826-1.276 3.965-2.985 7.884-5.127 11.758a100.413 100.413 0 0 1-7.314 11.211 162.124 162.124 0 0 1-8.408 10.322c-5.738 6.44-12.145 12.697-19.22 18.77-2.267 1.946-3.612 4.768-3.612 7.756v2.253h55.166a8.409 8.409 0 0 0-8.409-8.408h-32.881c7.566-6.79 14.333-13.854 20.303-21.191a147.24 147.24 0 0 0 7.451-10.049 87.122 87.122 0 0 0 6.495-11.006 67.002 67.002 0 0 0 4.58-11.553c1.185-3.919 1.777-7.815 1.777-11.689 0-3.92-.638-7.428-1.914-10.527-1.231-3.145-2.94-5.834-5.127-8.067-2.188-2.233-4.762-3.942-7.725-5.127C182.236.592 179.114 0 175.787 0h-4.17c-3.327 0-6.448.638-9.365 1.914-2.917 1.23-5.469 2.94-7.656 5.127-2.188 2.188-3.92 4.74-5.196 7.656-1.23 2.917-1.845 6.039-1.845 9.366a2.974 2.974 0 0 0 2.496 2.935l2.409.392a3.371 3.371 0 0 0 3.913-3.328c0-2.187.41-4.215 1.231-6.084.82-1.914 1.936-3.577 3.349-4.99a15.982 15.982 0 0 1 4.922-3.35Z" fill="currentColor"/>
</svg>
......@@ -11,15 +11,18 @@ 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';
import type { SmartContract } from 'types/api/contract';
import type { SmartContract, SmartContractReadMethod, SmartContractWriteMethod } from 'types/api/contract';
import type { IndexingStatus } from 'types/api/indexingStatus';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { RawTracesResponse } from 'types/api/rawTrace';
import type { Stats, Charts, HomeStats } from 'types/api/stats';
import type { SearchResult, SearchResultFilters } from 'types/api/search';
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';
......@@ -67,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,
},
......@@ -162,11 +170,31 @@ 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: {
path: '/api/v2/smart-contracts/:id',
},
contract_methods_read: {
path: '/api/v2/smart-contracts/:id/methods-read',
},
contract_methods_read_proxy: {
path: '/api/v2/smart-contracts/:id/methods-read-proxy',
},
contract_method_query: {
path: '/api/v2/smart-contracts/:id/query-read-method',
},
contract_methods_write: {
path: '/api/v2/smart-contracts/:id/methods-write',
},
contract_methods_write_proxy: {
path: '/api/v2/smart-contracts/:id/methods-write-proxy',
},
// TOKEN
token: {
......@@ -201,6 +229,23 @@ export const RESOURCES = {
path: '/api/v2/main-page/indexing-status',
},
// SEARCH
search: {
path: '/api/v2/search',
paginationFields: [
'address_hash' as const,
'block_hash' as const,
'holder_count' as const,
'inserted_at' as const,
'item_type' as const,
'items_count' as const,
'name' as const,
'q' as const,
'tx_hash' as const,
],
filterFields: [ 'q' ],
},
// DEPRECATED
old_api: {
path: '/api',
......@@ -231,7 +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>;
......@@ -251,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 :
......@@ -273,10 +320,16 @@ 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 :
Q extends 'search' ? SearchResult :
Q extends 'contract' ? SmartContract :
Q extends 'contract_methods_read' ? Array<SmartContractReadMethod> :
Q extends 'contract_methods_read_proxy' ? Array<SmartContractReadMethod> :
Q extends 'contract_methods_write' ? Array<SmartContractWriteMethod> :
Q extends 'contract_methods_write_proxy' ? Array<SmartContractWriteMethod> :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -287,5 +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 */
......@@ -6,18 +6,18 @@ import useFetch from 'lib/hooks/useFetch';
import buildUrl from './buildUrl';
import { RESOURCES } from './resources';
import type { ResourceError, ApiResource } from './resources';
import type { ApiResource } from './resources';
export interface Params {
pathParams?: Record<string, string | undefined>;
queryParams?: Record<string, string | number | undefined>;
fetchParams?: Pick<FetchParams, 'body' | 'method'>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal'>;
}
export default function useApiFetch() {
const fetch = useFetch();
return React.useCallback(<R extends keyof typeof RESOURCES, SuccessType = unknown, ErrorType = ResourceError>(
return React.useCallback(<R extends keyof typeof RESOURCES, SuccessType = unknown, ErrorType = unknown>(
resourceName: R,
{ pathParams, queryParams, fetchParams }: Params = {},
) => {
......
export default function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
import escapeRegExp from 'lib/escapeRegExp';
export default function highlightText(text: string, query: string) {
const regex = new RegExp('(' + escapeRegExp(query) + ')', 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
import React from 'react';
export default function useDebounce(value: string, delay: number) {
const [ debouncedValue, setDebouncedValue ] = React.useState(value);
React.useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[ value, delay ],
);
return debouncedValue;
}
......@@ -10,6 +10,7 @@ import { getResourceKey } from 'lib/api/useApiQuery';
export interface Params {
method?: RequestInit['method'];
headers?: RequestInit['headers'];
signal?: RequestInit['signal'];
body?: Record<string, unknown>;
credentials?: RequestCredentials;
}
......@@ -19,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,
......
import { useQueryClient } from '@tanstack/react-query';
import difference from 'lodash/difference';
import mapValues from 'lodash/mapValues';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
......@@ -43,7 +44,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
const isMounted = React.useRef(false);
const canGoBackwards = React.useRef(!router.query.page);
const queryParams = { ...filters, ...pageParams[page] };
const queryParams = { ...pageParams[page], ...filters };
const scrollToTop = useCallback(() => {
scrollRef?.current ? scrollRef.current.scrollIntoView(true) : animateScroll.scrollToTop({ duration: 0 });
......@@ -73,7 +74,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
setPage(prev => prev + 1);
const nextPageQuery = { ...router.query };
Object.entries(nextPageParams).forEach(([ key, val ]) => nextPageQuery[key] = val.toString());
Object.entries(nextPageParams).forEach(([ key, val ]) => nextPageQuery[key] = String(val));
nextPageQuery.page = String(page + 1);
setHasPagination(true);
......@@ -87,7 +88,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
// we dont have pagination params for the first page
let nextPageQuery: typeof router.query = { ...router.query };
if (page === 2) {
nextPageQuery = omit(router.query, resource.paginationFields, 'page');
nextPageQuery = omit(router.query, difference(resource.paginationFields, resource.filterFields), 'page');
canGoBackwards.current = true;
} else {
const nextPageParams = pageParams[page - 1];
......@@ -102,13 +103,14 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
page === 2 && queryClient.removeQueries({ queryKey: [ resourceName ] });
});
setHasPagination(true);
}, [ router, page, resource.paginationFields, pageParams, scrollToTop, queryClient, resourceName ]);
}, [ router, page, resource.paginationFields, resource.filterFields, pageParams, scrollToTop, queryClient, resourceName ]);
const resetPage = useCallback(() => {
queryClient.removeQueries({ queryKey: [ resourceName ] });
scrollToTop();
router.push({ pathname: router.pathname, query: omit(router.query, resource.paginationFields, 'page') }, undefined, { shallow: true }).then(() => {
const nextRouterQuery = omit(router.query, difference(resource.paginationFields, resource.filterFields), 'page');
router.push({ pathname: router.pathname, query: nextRouterQuery }, undefined, { shallow: true }).then(() => {
queryClient.removeQueries({ queryKey: [ resourceName ] });
setPage(1);
setPageParams({});
......@@ -121,10 +123,10 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
});
setHasPagination(true);
}, [ queryClient, resourceName, router, resource.paginationFields, scrollToTop ]);
}, [ queryClient, resourceName, router, resource.paginationFields, resource.filterFields, scrollToTop ]);
const onFilterChange = useCallback((newFilters: PaginationFilters<Resource> | undefined) => {
const newQuery = omit(router.query, resource.paginationFields, 'page', resource.filterFields);
const newQuery = omit<typeof router.query>(router.query, resource.paginationFields, 'page', resource.filterFields);
if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => {
if (value && value.length) {
......@@ -132,7 +134,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}
});
}
setHasPagination(false);
scrollToTop();
router.push(
{
......@@ -147,14 +149,12 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
});
}, [ router, resource.paginationFields, resource.filterFields, scrollToTop ]);
const hasPaginationParams = Object.keys(currPageParams || {}).length > 0;
const nextPageParams = data?.next_page_params;
const pagination = {
page,
onNextPageClick,
onPrevPageClick,
hasPaginationParams,
resetPage,
hasNextPage: nextPageParams ? Object.keys(nextPageParams).length > 0 : false,
canGoBackwards: canGoBackwards.current,
......
import React from 'react';
// run effect only if value is updated since initial mount
const useUpdateValueEffect = (effect: () => void, value: string) => {
const mountedRef = React.useRef(false);
const valueRef = React.useRef<string>();
const isChangedRef = React.useRef(false);
React.useEffect(() => {
mountedRef.current = true;
valueRef.current = value;
return () => {
mountedRef.current = false;
valueRef.current = undefined;
isChangedRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (mountedRef.current && (value !== valueRef.current || isChangedRef.current)) {
isChangedRef.current = true;
return effect();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ value ]);
};
export default useUpdateValueEffect;
import type { SearchResultToken, SearchResultBlock, SearchResultAddressOrContract, SearchResultTx, SearchResult } from 'types/api/search';
export const token1: SearchResultToken = {
address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
address_url: '/address/0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
name: 'Toms NFT',
symbol: 'TNT',
token_url: '/token/0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
type: 'token' as const,
};
export const token2: SearchResultToken = {
address: '0xC35Cc7223B0175245E9964f2E3119c261E8e21F9',
address_url: '/address/0xC35Cc7223B0175245E9964f2E3119c261E8e21F9',
name: 'TomToken',
symbol: 'pdE1B',
token_url: '/token/0xC35Cc7223B0175245E9964f2E3119c261E8e21F9',
type: 'token' as const,
};
export const block1: SearchResultBlock = {
block_hash: '0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd1',
block_number: 8198536,
type: 'block' as const,
url: '/block/0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd1',
};
export const address1: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: null,
type: 'address' as const,
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
};
export const contract1: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: 'Unknown contract in this network',
type: 'contract' as const,
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
};
export const tx1: SearchResultTx = {
tx_hash: '0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
type: 'transaction' as const,
url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
};
export const baseResponse: SearchResult = {
items: [
token1,
token2,
block1,
address1,
contract1,
tx1,
],
next_page_params: null,
};
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import SearchResults from 'ui/pages/SearchResults';
const SearchResultsPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head>
<title>{ title }</title>
</Head>
<SearchResults/>
</>
);
};
export default SearchResultsPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
......@@ -16,8 +16,8 @@ const size = {
xs: defineStyle({
fontSize: 'md',
lineHeight: '24px',
px: '4px',
py: '12px',
px: '8px',
py: '4px',
h: '32px',
borderRadius: 'base',
}),
......
......@@ -14,7 +14,7 @@ const $arrowBg = cssVar('popper-arrow-bg');
const $arrowShadowColor = cssVar('popper-arrow-shadow-color');
const baseStylePopper = defineStyle({
zIndex: 20,
zIndex: 'popover',
});
const baseStyleContent = defineStyle((props) => {
......
......@@ -9,6 +9,10 @@ const global = (props: StyleFunctionProps) => ({
...getDefaultTransitionProps(),
'-webkit-tap-highlight-color': 'transparent',
},
mark: {
bgColor: 'yellow.200',
color: 'inherit',
},
'svg *::selection': {
color: 'none',
background: 'none',
......
......@@ -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;
......
......@@ -13,3 +13,37 @@ export interface SmartContract {
source_code: string | null;
can_be_visualized_via_sol2uml: boolean | null;
}
export interface SmartContractMethodBase {
inputs: Array<SmartContractMethodInput>;
outputs: Array<SmartContractMethodOutput>;
constant: boolean;
name: string;
stateMutability: string;
type: 'function';
payable: boolean;
}
export interface SmartContractReadMethod extends SmartContractMethodBase {
method_id: string;
}
export interface SmartContractWriteFallback {
payable: true;
stateMutability: 'payable';
type: 'fallback';
}
export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWriteFallback;
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput {
internalType: string;
name: string;
type: string;
}
export interface SmartContractMethodOutput extends SmartContractMethodInput {
value?: string;
}
export type SearchResultType = 'token' | 'address' | 'block' | 'transaction' | 'contract';
export interface SearchResultToken {
type: 'token';
name: string;
symbol: string;
address: string;
token_url: string;
address_url: string;
}
export interface SearchResultAddressOrContract {
type: 'address' | 'contract';
name: string | null;
address: string;
url: string;
}
export interface SearchResultBlock {
type: 'block';
block_number: number;
block_hash: string;
url: string;
}
export interface SearchResultTx {
type: 'transaction';
tx_hash: string;
url: string;
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx;
export interface SearchResult {
items: Array<SearchResultItem>;
next_page_params: {
'address_hash': string | null;
'block_hash': string | null;
'holder_count': number | null;
'inserted_at': string | null;
'item_type': SearchResultType;
'items_count': number;
'name': string;
'q': string;
'tx_hash': string | null;
} | null;
}
export interface SearchResultFilters {
q: 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;
......@@ -4,7 +4,6 @@ import React from 'react';
import appConfig from 'configs/app/config';
import useApiQuery from 'lib/api/useApiQuery';
import ChartWidget from 'ui/shared/chart/ChartWidget';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
interface Props {
addressHash: string;
......@@ -20,16 +19,9 @@ const AddressCoinBalanceChart = ({ addressHash }: Props) => {
value: BigNumber(value).div(10 ** appConfig.network.currency.decimals).toNumber(),
})), [ data ]);
if (isError) {
return <DataFetchAlert/>;
}
if (!items?.length) {
return null;
}
return (
<ChartWidget
isError={ isError }
title="Balances"
items={ items }
isLoading={ isLoading }
......
import { Box, Button, chakra, Flex, Icon, Text, useColorModeValue } from '@chakra-ui/react';
import _fromPairs from 'lodash/fromPairs';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type { MethodFormFields } from './types';
import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract';
import appConfig from 'configs/app/config';
import arrowIcon from 'icons/arrows/down-right.svg';
import ContractMethodField from './ContractMethodField';
interface Props<T extends SmartContractMethod> {
data: T;
caller: (data: T, args: Array<string>) => Promise<Array<Array<string>>>;
isWrite?: boolean;
}
const getFieldName = (name: string, index: number): string => name || String(index);
const sortFields = (data: Array<SmartContractMethodInput>) => ([ a ]: [string, string], [ b ]: [string, string]): 1 | -1 | 0 => {
const fieldNames = data.map(({ name }, index) => getFieldName(name, index));
const indexA = fieldNames.indexOf(a);
const indexB = fieldNames.indexOf(b);
if (indexA > indexB) {
return 1;
}
if (indexA < indexB) {
return -1;
}
return 0;
};
const ContractMethodCallable = <T extends SmartContractMethod>({ data, caller, isWrite }: Props<T>) => {
const inputs = React.useMemo(() => {
return data.payable && (!('inputs' in data) || data.inputs.length === 0) ? [ {
name: 'value',
type: appConfig.network.currency.symbol,
internalType: appConfig.network.currency.symbol,
} as SmartContractMethodInput ] : data.inputs;
}, [ data ]);
const { control, handleSubmit, setValue } = useForm<MethodFormFields>({
defaultValues: _fromPairs(inputs.map(({ name }, index) => [ getFieldName(name, index), '' ])),
});
const [ result, setResult ] = React.useState<Array<Array<string>>>([ ]);
const onSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
const args = Object.entries(formData)
.sort(sortFields(inputs))
.map(([ , value ]) => value);
const result = await caller(data, args);
setResult(result);
}, [ caller, data, inputs ]);
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
return (
<Box>
<chakra.form
noValidate
display="flex"
columnGap={ 3 }
flexDir={{ base: 'column', lg: 'row' }}
rowGap={ 2 }
alignItems={{ base: 'flex-start', lg: 'center' }}
onSubmit={ handleSubmit(onSubmit) }
flexWrap="wrap"
>
{ inputs.map(({ type, name }, index) => {
const fieldName = getFieldName(name, index);
return (
<ContractMethodField
key={ fieldName }
name={ fieldName }
placeholder={ `${ name }(${ type })` }
control={ control }
setValue={ setValue }
/>
);
}) }
<Button
variant="outline"
size="sm"
flexShrink={ 0 }
type="submit"
>
{ isWrite ? 'Write' : 'Query' }
</Button>
</chakra.form>
{ 'outputs' in data && data.outputs.length > 0 && (
<Flex mt={ 3 }>
<Icon as={ arrowIcon } boxSize={ 5 } mr={ 1 }/>
<Text>{ data.outputs.map(({ type }) => type).join(', ') }</Text>
</Flex>
) }
{ result.length > 0 && (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm">
<p>
[ <chakra.span fontWeight={ 600 }>{ 'name' in data ? data.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.map(([ key, value ], index) => (
<chakra.p key={ index } whiteSpace="break-spaces" wordBreak="break-all"> { key }: { value }</chakra.p>
)) }
<p>]</p>
</Box>
) }
</Box>
);
};
export default React.memo(ContractMethodCallable) as typeof ContractMethodCallable;
import { Checkbox, Flex, chakra } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import type { ChangeEvent } from 'react';
import React from 'react';
import type { SmartContractMethodOutput } from 'types/api/contract';
import appConfig from 'configs/app/config';
import { WEI } from 'lib/consts';
interface Props {
data: SmartContractMethodOutput;
}
const ContractMethodStatic = ({ data }: Props) => {
const isBigInt = data.type.includes('int256') || data.type.includes('int128');
const [ value, setValue ] = React.useState(isBigInt && data.value ? BigNumber(data.value).toFixed() : data.value);
const [ label, setLabel ] = React.useState('WEI');
const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
if (!data.value) {
return;
}
if (event.target.checked) {
setValue(BigNumber(data.value).div(WEI).toFixed());
setLabel(appConfig.network.currency.symbol || 'ETH');
} else {
setValue(BigNumber(data.value).toFixed());
setLabel('WEI');
}
}, [ data.value ]);
return (
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }>
<chakra.span wordBreak="break-all">({ data.type }): { value }</chakra.span>
{ isBigInt && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
</Flex>
);
};
export default ContractMethodStatic;
import { FormControl, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, UseFormSetValue } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { MethodFormFields } from './types';
import InputClearButton from 'ui/shared/InputClearButton';
interface Props {
control: Control<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
placeholder: string;
name: string;
}
const ContractMethodField = ({ control, name, placeholder, setValue }: Props) => {
const ref = React.useRef<HTMLInputElement>(null);
const handleClear = React.useCallback(() => {
setValue(name, '');
ref.current?.focus();
}, [ name, setValue ]);
const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps<MethodFormFields> }) => {
return (
<FormControl id={ name } maxW={{ base: '100%', lg: 'calc((100% - 24px) / 3)' }}>
<InputGroup size="xs">
<Input
{ ...field }
ref={ ref }
placeholder={ placeholder }
/>
{ field.value && (
<InputRightElement>
<InputClearButton onClick={ handleClear }/>
</InputRightElement>
) }
</InputGroup>
</FormControl>
);
}, [ handleClear, name, placeholder ]);
return (
<Controller
name={ name }
control={ control }
render={ renderInput }
/>
);
};
export default React.memo(ContractMethodField);
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, Flex, Icon, Link, Tooltip } from '@chakra-ui/react';
import _range from 'lodash/range';
import React from 'react';
import type { SmartContractMethod } from 'types/api/contract';
import infoIcon from 'icons/info.svg';
interface Props<T extends SmartContractMethod> {
data: Array<T>;
renderContent: (item: T, index: number, id: number) => React.ReactNode;
}
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, renderContent }: Props<T>) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>([]);
const [ id, setId ] = React.useState(0);
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue);
}, []);
const handleExpandAll = React.useCallback(() => {
if (!data) {
return;
}
if (expandedSections.length < data.length) {
setExpandedSections(_range(0, data.length));
} else {
setExpandedSections([]);
}
}, [ data, expandedSections.length ]);
const handleReset = React.useCallback(() => {
setId((id) => id + 1);
}, []);
return (
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => {
return (
<AccordionItem key={ index } as="section">
<h2>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }}>
<Box as="span" fontFamily="heading" fontWeight={ 500 } fontSize="lg" mr={ 1 }>
{ index + 1 }. { item.type === 'fallback' ? 'fallback' : item.name }
</Box>
{ item.type === 'fallback' && (
<Tooltip
label={ `The fallback function is executed on a call to the contract if none of the other functions match
the given function signature, or if no data was supplied at all and there is no receive Ether function.
The fallback function always receives data, but in order to also receive Ether it must be marked payable.` }
placement="top"
maxW="320px"
>
<Box cursor="pointer" display="inherit">
<Icon as={ infoIcon } boxSize={ 5 }/>
</Box>
</Tooltip>
) }
<AccordionIcon/>
</AccordionButton>
</h2>
<AccordionPanel pb={ 4 } px={ 0 }>
{ renderContent(item, index, id) }
</AccordionPanel>
</AccordionItem>
);
}) }
<Flex columnGap={ 3 } position="absolute" top={ 0 } right={ 0 } py={ 3 } lineHeight="27px">
<Link onClick={ handleExpandAll }>{ expandedSections.length === data.length ? 'Collapse' : 'Expand' } all</Link>
<Link onClick={ handleReset }>Reset</Link>
</Flex>
</Accordion>
);
};
export default React.memo(ContractMethodsAccordion) as typeof ContractMethodsAccordion;
import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { SmartContractReadMethod } from 'types/api/contract';
import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractMethodCallable from './ContractMethodCallable';
import ContractMethodConstant from './ContractMethodConstant';
interface Props {
isProxy?: boolean;
}
const ContractRead = ({ isProxy }: Props) => {
const router = useRouter();
const apiFetch = useApiFetch();
const addressHash = router.query.id?.toString();
const { data, isLoading, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', {
pathParams: { id: addressHash },
queryOptions: {
enabled: Boolean(router.query.id),
},
});
const contractCaller = React.useCallback(async(item: SmartContractReadMethod, args: Array<string>) => {
await apiFetch('contract_method_query', {
pathParams: { id: addressHash },
fetchParams: {
method: 'POST',
body: {
args,
method_id: item.method_id,
contract_type: isProxy ? 'proxy' : 'regular',
},
},
});
return [ [ 'string', 'this is mock' ] ];
}, [ addressHash, apiFetch, isProxy ]);
const renderContent = React.useCallback((item: SmartContractReadMethod, index: number, id: number) => {
if (item.inputs.length === 0) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ item.outputs.map((output, index) => <ContractMethodConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodCallable
key={ id + '_' + index }
data={ item }
caller={ contractCaller }
/>
);
}, [ contractCaller ]);
if (isError) {
return <DataFetchAlert/>;
}
if (isLoading) {
return <ContentLoader/>;
}
return <ContractMethodsAccordion data={ data } renderContent={ renderContent }/>;
};
export default ContractRead;
import { useRouter } from 'next/router';
import React from 'react';
import type { SmartContractWriteMethod } from 'types/api/contract';
import useApiQuery from 'lib/api/useApiQuery';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractMethodCallable from './ContractMethodCallable';
interface Props {
isProxy?: boolean;
}
const ContractWrite = ({ isProxy }: Props) => {
const router = useRouter();
const addressHash = router.query.id?.toString();
const { data, isLoading, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', {
pathParams: { id: addressHash },
queryOptions: {
enabled: Boolean(router.query.id),
},
});
const contractInfo = useApiQuery('contract', {
pathParams: { id: addressHash },
queryOptions: {
enabled: Boolean(router.query.id),
},
});
const contractCaller = React.useCallback(async() => {
// eslint-disable-next-line no-console
console.log('__>__', contractInfo);
return [ [ 'string', 'this is mock' ] ];
}, [ contractInfo ]);
const renderContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return (
<ContractMethodCallable
key={ id + '_' + index }
data={ item }
caller={ contractCaller }
isWrite
/>
);
}, [ contractCaller ]);
if (isError) {
return <DataFetchAlert/>;
}
if (isLoading) {
return <ContentLoader/>;
}
return <ContractMethodsAccordion data={ data } renderContent={ renderContent }/>;
};
export default ContractWrite;
export type MethodFormFields = Record<string, string>;
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 } 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 useApiQuery from 'lib/api/useApiQuery';
......@@ -12,15 +13,26 @@ 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';
import ContractRead from 'ui/address/contract/ContractRead';
import ContractWrite from 'ui/address/contract/ContractWrite';
import TextAd from 'ui/shared/ad/TextAd';
import Page from 'ui/shared/Page/Page';
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();
......@@ -44,19 +56,19 @@ const AddressPageContent = () => {
{ id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
undefined,
addressQuery.data?.has_methods_read ?
{ id: 'read_contract', title: 'Read contract', component: <div>Read contract</div> } :
{ id: 'read_contract', title: 'Read contract', component: <ContractRead/> } :
undefined,
addressQuery.data?.has_methods_read_proxy ?
{ id: 'read_proxy', title: 'Read proxy', component: <div>Read proxy</div> } :
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead isProxy/> } :
undefined,
addressQuery.data?.has_custom_methods_read ?
{ id: 'read_custom_methods', title: 'Read custom methods', component: <div>Read custom methods</div> } :
undefined,
addressQuery.data?.has_methods_write ?
{ id: 'write_contract', title: 'Write contract', component: <div>Write contract</div> } :
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite/> } :
undefined,
addressQuery.data?.has_methods_write_proxy ?
{ id: 'write_proxy', title: 'Write proxy', component: <div>Write proxy</div> } :
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite isProxy/> } :
undefined,
addressQuery.data?.has_custom_methods_write ?
{ id: 'write_custom_methods', title: 'Write custom methods', component: <div>Write custom methods</div> } :
......@@ -70,7 +82,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 ?
......@@ -81,7 +93,7 @@ const AddressPageContent = () => {
id: 'contract',
title: 'Contract',
component: <AddressContract tabs={ contractTabs }/>,
subTabs: contractTabs,
subTabs: contractTabs.map(tab => tab.id),
} : undefined,
].filter(notEmpty);
}, [ addressQuery.data, contractTabs ]);
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as searchMock from 'mocks/search/index';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import SearchResults from './SearchResults';
test('search by name +@mobile +@dark-mode', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: 'o' },
},
};
await page.route(buildApiUrl('search') + '?q=o', (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.token1,
searchMock.token2,
searchMock.contract1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by address hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.address1.address },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.address1.address }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.address1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by block number +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.block1.block_number },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.block1.block_number }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.block1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by block hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.block1.block_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.block1.block_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.block1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by tx hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.tx1.tx_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.tx1.tx_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.tx1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
import { Box, chakra, Table, Tbody, Tr, Th, Skeleton, Show, Hide } from '@chakra-ui/react';
import type { FormEvent } from 'react';
import React from 'react';
import SearchResultListItem from 'ui/searchResults/SearchResultListItem';
import SearchResultTableItem from 'ui/searchResults/SearchResultTableItem';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import { default as Thead } from 'ui/shared/TheadSticky';
import Header from 'ui/snippets/header/Header';
import SearchBarInput from 'ui/snippets/searchBar/SearchBarInput';
import useSearchQuery from 'ui/snippets/searchBar/useSearchQuery';
const SearchResultsPageContent = () => {
const { query, searchTerm, debouncedSearchTerm, handleSearchTermChange } = useSearchQuery(true);
const { data, isError, isLoading, pagination, isPaginationVisible } = query;
const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
}, [ ]);
const content = (() => {
if (isError) {
return <DataFetchAlert/>;
}
if (isLoading) {
return (
<Box>
<Show below="lg">
<SkeletonList/>
</Show>
<Hide below="lg">
<SkeletonTable columns={ [ '50%', '50%', '150px' ] }/>
</Hide>
</Box>
);
}
if (data.items.length === 0) {
return null;
}
return (
<>
<Show below="lg" ssr={ false }>
{ data.items.map((item, index) => <SearchResultListItem key={ index } data={ item } searchTerm={ debouncedSearchTerm }/>) }
</Show>
<Hide below="lg" ssr={ false }>
<Table variant="simple" size="md" fontWeight={ 500 }>
<Thead top={ isPaginationVisible ? 80 : 0 }>
<Tr>
<Th width="50%">Search Result</Th>
<Th width="50%"/>
<Th width="150px">Category</Th>
</Tr>
</Thead>
<Tbody>
{ data.items.map((item, index) => <SearchResultTableItem key={ index } data={ item } searchTerm={ debouncedSearchTerm }/>) }
</Tbody>
</Table>
</Hide>
</>
);
})();
const bar = (() => {
if (isError) {
return null;
}
const text = isLoading ? (
<Skeleton h={ 6 } w="280px" borderRadius="full" mb={ isPaginationVisible ? 0 : 6 }/>
) : (
(
<Box mb={ isPaginationVisible ? 0 : 6 } lineHeight="32px">
<span>Found </span>
<chakra.span fontWeight={ 700 }>
{ pagination.page > 1 ? 50 : data.items.length }{ data.next_page_params || pagination.page > 1 ? '+' : '' }
</chakra.span>
<span> matching result{ data.items.length > 1 || pagination.page > 1 ? 's' : '' } for </span>
<chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span>
</Box>
)
);
if (!isPaginationVisible) {
return text;
}
return (
<>
<Box display={{ base: 'block', lg: 'none' }}>{ text }</Box>
<ActionBar mt={{ base: 0, lg: -6 }} alignItems="center">
<Box display={{ base: 'none', lg: 'block' }}>{ text }</Box>
<Pagination { ...pagination }/>
</ActionBar>
</>
);
})();
const inputRef = React.useRef<HTMLFormElement>(null);
const handelHide = React.useCallback(() => {
inputRef.current?.querySelector('input')?.blur();
}, [ ]);
const handleClear = React.useCallback(() => {
handleSearchTermChange('');
inputRef.current?.querySelector('input')?.focus();
}, [ handleSearchTermChange ]);
const renderSearchBar = React.useCallback(() => {
return (
<SearchBarInput
ref={ inputRef }
onChange={ handleSearchTermChange }
onSubmit={ handleSubmit }
value={ searchTerm }
onHide={ handelHide }
onClear={ handleClear }
/>
);
}, [ handleSearchTermChange, handleSubmit, searchTerm, handelHide, handleClear ]);
const renderHeader = React.useCallback(() => {
return <Header renderSearchBar={ renderSearchBar }/>;
}, [ renderSearchBar ]);
return (
<Page renderHeader={ renderHeader }>
<PageTitle text="Search results"/>
{ bar }
{ content }
</Page>
);
};
export default SearchResultsPageContent;
......@@ -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 }
/>
......
......@@ -35,7 +35,7 @@ const Transactions = () => {
];
return (
<Page hideMobileHeaderOnScrollDown>
<Page>
<Box h="100%">
<PageTitle text="Transactions" withTextAd/>
<RoutedTabs
......
import { Text, Link, Flex, Icon, Box, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultItem } from 'types/api/search';
import blockIcon from 'icons/block.svg';
import txIcon from 'icons/transactions.svg';
import highlightText from 'lib/highlightText';
import link from 'lib/link/link';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import ListItemMobile from 'ui/shared/ListItemMobile';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
data: SearchResultItem;
searchTerm: string;
}
const SearchResultListItem = ({ data, searchTerm }: Props) => {
const firstRow = (() => {
switch (data.type) {
case 'token': {
const name = data.name + (data.symbol ? ` (${ data.symbol })` : '');
return (
<Flex alignItems="flex-start">
<TokenLogo boxSize={ 6 } hash={ data.address } name={ data.name } flexShrink={ 0 }/>
<Link ml={ 2 } href={ link('token_index', { hash: data.address }) } fontWeight={ 700 } wordBreak="break-all">
<chakra.span dangerouslySetInnerHTML={{ __html: highlightText(name, searchTerm) }}/>
</Link>
</Flex>
);
}
case 'contract':
case 'address': {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
return (
<Address>
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" whiteSpace="nowrap" overflow="hidden">
<AddressLink hash={ data.address } fontWeight={ 700 } display="block" w="100%"/>
</Box>
</Address>
);
}
case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return (
<Flex alignItems="center">
<Icon as={ blockIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<Link fontWeight={ 700 } href={ link('block', { id: String(data.block_number) }) }>
<Box as={ shouldHighlightHash ? 'span' : 'mark' }>{ data.block_number }</Box>
</Link>
</Flex>
);
}
case 'transaction': {
return (
<Flex alignItems="center" overflow="hidden">
<Icon as={ txIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<chakra.mark display="block" overflow="hidden">
<AddressLink hash={ data.tx_hash } type="transaction" fontWeight={ 700 } display="block"/>
</chakra.mark>
</Flex>
);
}
}
})();
const secondRow = (() => {
switch (data.type) {
case 'token': {
return (
<HashStringShortenDynamic hash={ data.address }/>
);
}
case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return (
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" w="100%" whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ data.block_hash }/>
</Box>
);
}
case 'contract':
case 'address': {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
return data.name ? <span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? data.name : highlightText(data.name, searchTerm) }}/> : null;
}
default:
return null;
}
})();
return (
<ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }>
<Flex justifyContent="space-between" w="100%" overflow="hidden" lineHeight={ 6 }>
{ firstRow }
<Text variant="secondary" ml={ 8 } textTransform="capitalize">{ data.type }</Text>
</Flex>
{ secondRow }
</ListItemMobile>
);
};
export default SearchResultListItem;
import { Tr, Td, Text, Link, Flex, Icon, Box } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultItem } from 'types/api/search';
import blockIcon from 'icons/block.svg';
import txIcon from 'icons/transactions.svg';
import highlightText from 'lib/highlightText';
import link from 'lib/link/link';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
data: SearchResultItem;
searchTerm: string;
}
const SearchResultTableItem = ({ data, searchTerm }: Props) => {
const content = (() => {
switch (data.type) {
case 'token': {
const name = data.name + (data.symbol ? ` (${ data.symbol })` : '');
return (
<>
<Td fontSize="sm">
<Flex alignItems="center">
<TokenLogo boxSize={ 6 } hash={ data.address } name={ data.name } flexShrink={ 0 }/>
<Link ml={ 2 } href={ link('token_index', { hash: data.address }) } fontWeight={ 700 } wordBreak="break-all">
<span dangerouslySetInnerHTML={{ __html: highlightText(name, searchTerm) }}/>
</Link>
</Flex>
</Td>
<Td fontSize="sm" verticalAlign="middle">
<Box whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ data.address }/>
</Box>
</Td>
</>
);
}
case 'contract':
case 'address': {
if (data.name) {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
return (
<>
<Td fontSize="sm">
<Flex alignItems="center" overflow="hidden">
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<Link href={ link('address_index', { id: data.address }) } fontWeight={ 700 } overflow="hidden" whiteSpace="nowrap">
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block">
<HashStringShortenDynamic hash={ data.address }/>
</Box>
</Link>
</Flex>
</Td>
<Td fontSize="sm" verticalAlign="middle">
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? data.name : highlightText(data.name, searchTerm) }}/>
</Td>
</>
);
}
return (
<Td colSpan={ 2 } fontSize="sm">
<Address>
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<mark>
<AddressLink hash={ data.address } type="address" fontWeight={ 700 }/>
</mark>
</Address>
</Td>
);
}
case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return (
<>
<Td fontSize="sm">
<Flex alignItems="center">
<Icon as={ blockIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<Link fontWeight={ 700 } href={ link('block', { id: String(data.block_number) }) }>
<Box as={ shouldHighlightHash ? 'span' : 'mark' }>{ data.block_number }</Box>
</Link>
</Flex>
</Td>
<Td fontSize="sm" verticalAlign="middle">
<Box overflow="hidden" whiteSpace="nowrap" as={ shouldHighlightHash ? 'mark' : 'span' } display="block">
<HashStringShortenDynamic hash={ data.block_hash }/>
</Box>
</Td>
</>
);
}
case 'transaction': {
return (
<Td colSpan={ 2 } fontSize="sm">
<Flex alignItems="center">
<Icon as={ txIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<mark>
<AddressLink hash={ data.tx_hash } type="transaction" fontWeight={ 700 }/>
</mark>
</Flex>
</Td>
);
}
}
})();
return (
<Tr>
{ content }
<Td fontSize="sm" textTransform="capitalize" verticalAlign="middle">
<Text variant="secondary">
{ data.type }
</Text>
</Td>
</Tr>
);
};
export default React.memo(SearchResultTableItem);
......@@ -9,9 +9,10 @@ const runnerAnimation = keyframes`
interface Props {
className?: string;
text?: string;
}
const ContentLoader = ({ className }: Props) => {
const ContentLoader = ({ className, text }: Props) => {
return (
<Box display="inline-block" className={ className }>
<Box
......@@ -30,7 +31,9 @@ const ContentLoader = ({ className }: Props) => {
borderRadius: 'full',
}}
/>
<Text mt={ 6 } variant="secondary">Loading data, please wait... </Text>
<Text mt={ 6 } variant="secondary">
{ text || 'Loading data, please wait...' }
</Text>
</Box>
);
};
......
import { chakra, Icon, IconButton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import crossIcon from 'icons/cross.svg';
import errorIcon from 'icons/status/error.svg';
interface Props {
onClick: () => void;
className?: string;
}
const InputClearButton = ({ onClick }: Props) => {
const iconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
const InputClearButton = ({ onClick, className }: Props) => {
const iconColor = useColorModeValue('gray.300', 'gray.600');
const iconColorHover = useColorModeValue('gray.200', 'gray.500');
return (
<IconButton
colorScheme="gray"
className={ className }
colorScheme="none"
aria-label="Clear input"
title="Clear input"
boxSize={ 6 }
icon={ <Icon as={ crossIcon } boxSize={ 4 } color={ iconColor }/> }
icon={ <Icon as={ errorIcon } boxSize={ 3 } color={ iconColor } focusable={ false } _hover={{ color: iconColorHover }}/> }
size="sm"
onClick={ onClick }
/>
......
......@@ -12,22 +12,22 @@ import NavigationDesktop from 'ui/snippets/navigation/NavigationDesktop';
interface Props {
children: React.ReactNode;
wrapChildren?: boolean;
hideMobileHeaderOnScrollDown?: boolean;
isHomePage?: boolean;
renderHeader?: () => React.ReactNode;
}
const Page = ({
children,
wrapChildren = true,
hideMobileHeaderOnScrollDown,
isHomePage,
renderHeader,
}: Props) => {
useGetCsrfToken();
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) {
......@@ -46,7 +46,10 @@ const Page = ({
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Flex flexDir="column" flexGrow={ 1 } w={{ base: '100%', lg: 'auto' }}>
<Header isHomePage={ isHomePage } hideOnScrollDown={ hideMobileHeaderOnScrollDown }/>
{ renderHeader ?
renderHeader() :
<Header isHomePage={ isHomePage }/>
}
<ErrorBoundary renderErrorScreen={ renderErrorScreen }>
{ renderedChildren }
</ErrorBoundary>
......
......@@ -9,12 +9,11 @@ export type Props = {
onPrevPageClick: () => void;
resetPage: () => void;
hasNextPage: boolean;
hasPaginationParams?: boolean;
className?: string;
canGoBackwards: boolean;
}
const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNextPage, hasPaginationParams, className, canGoBackwards }: Props) => {
const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNextPage, className, canGoBackwards }: Props) => {
return (
<Flex
......@@ -26,7 +25,7 @@ const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNext
variant="outline"
size="sm"
onClick={ resetPage }
disabled={ !hasPaginationParams }
disabled={ page === 1 }
mr={ 4 }
>
First
......@@ -49,7 +48,6 @@ const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNext
fontWeight={ 400 }
h={ 8 }
cursor="unset"
disabled={ hasPaginationParams && page === 1 }
>
{ page }
</Button>
......
......@@ -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;
}
......
......@@ -2,7 +2,7 @@ export interface RoutedTab {
id: string;
title: string;
component: React.ReactNode;
subTabs?: Array<RoutedSubTab>;
subTabs?: Array<string>;
}
export type RoutedSubTab = Omit<RoutedTab, 'subTabs'>;
......
......@@ -10,10 +10,20 @@ type AdData = {
thumbnail: string;
url: string;
cta_button: string;
impressionUrl: string;
impressionUrl?: string;
};
}
// const MOCK: AdData = {
// ad: {
// url: 'https://unsplash.com/s/photos/cute-kitten',
// thumbnail: 'https://placekitten.com/40/40',
// name: 'All about kitties',
// description_short: 'To see millions picture of cute kitties',
// cta_button: 'click here',
// },
// };
const CoinzillaTextAd = ({ className }: {className?: string}) => {
const [ adData, setAdData ] = React.useState<AdData | null>(null);
useEffect(() => {
......
......@@ -7,14 +7,14 @@ import type { AddressParam } from 'types/api/addressParams';
import AddressContractIcon from 'ui/shared/address/AddressContractIcon';
type Props = {
address: AddressParam;
address: Pick<AddressParam, 'is_contract' | 'hash' | 'implementation_name'>;
className?: string;
}
const AddressIcon = ({ address, className }: Props) => {
if (address.is_contract) {
return (
<AddressContractIcon/>
<AddressContractIcon className={ className }/>
);
}
......
import { Box, Grid, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Text, Tooltip, useColorModeValue, VisuallyHidden } from '@chakra-ui/react';
import {
Box,
Flex,
Grid,
Icon,
IconButton, Link,
Menu,
MenuButton,
MenuItem,
MenuList,
Text,
Tooltip,
useColorModeValue,
VisuallyHidden,
} from '@chakra-ui/react';
import domToImage from 'dom-to-image';
import React, { useRef, useCallback, useState } from 'react';
......@@ -10,6 +24,7 @@ import scopeIcon from 'icons/scope.svg';
import svgFileIcon from 'icons/svg_file.svg';
import dotsIcon from 'icons/vertical_dots.svg';
import dayjs from 'lib/date/dayjs';
import { apos } from 'lib/html-entities';
import saveAsCSV from 'lib/saveAsCSV';
import ChartWidgetGraph from './ChartWidgetGraph';
......@@ -22,11 +37,12 @@ type Props = {
description?: string;
isLoading: boolean;
chartHeight?: string;
isError: boolean;
}
const DOWNLOAD_IMAGE_SCALE = 5;
const ChartWidget = ({ items, title, description, isLoading, chartHeight }: Props) => {
const ChartWidget = ({ items, title, description, isLoading, chartHeight, isError }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const [ isFullscreen, setIsFullscreen ] = useState(false);
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
......@@ -92,14 +108,15 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight }: Prop
}, [ items, title ]);
if (isLoading) {
return <ChartWidgetSkeleton hasDescription={ Boolean(description) } chartHeight={ chartHeight }/>;
return <ChartWidgetSkeleton hasDescription={ Boolean(description) }/>;
}
if (items) {
return (
<>
<Box
height={ chartHeight }
display="flex"
flexDirection="column"
ref={ ref }
padding={{ base: 3, lg: 4 }}
borderRadius="md"
......@@ -150,6 +167,7 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight }: Prop
/>
</Tooltip>
{ !isError && (
<Menu>
<MenuButton
gridColumn={ 3 }
......@@ -195,9 +213,11 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight }: Prop
</MenuItem>
</MenuList>
</Menu>
) }
</Grid>
<Box h={ chartHeight || 'auto' }>
{ items ? (
<Box h={ chartHeight || 'auto' } maxW="100%">
<ChartWidgetGraph
margin={{ bottom: 20 }}
items={ items }
......@@ -206,8 +226,26 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight }: Prop
title={ title }
/>
</Box>
) : (
<Flex
alignItems="center"
justifyContent="center"
flexGrow={ 1 }
py={ 4 }
>
<Text
variant="secondary"
fontSize="sm"
textAlign="center"
>
{ `The data didn${ apos }t load. Please, ` }
<Link href={ window.document.location.href }>try to reload the page.</Link>
</Text>
</Flex>
) }
</Box>
{ items && (
<FullscreenChartModal
isOpen={ isFullscreen }
items={ items }
......@@ -215,11 +253,9 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight }: Prop
description={ description }
onClose={ clearFullscreenChart }
/>
) }
</>
);
}
return null;
};
export default React.memo(ChartWidget);
......@@ -70,7 +70,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
}, [ isZoomResetInitial, items ]);
return (
<svg width={ width || '100%' } height={ height || '100%' } ref={ ref } cursor="pointer" id={ chartId } opacity={ width ? 1 : 0 }>
<svg width="100%" height={ height || '100%' } ref={ ref } cursor="pointer" id={ chartId } opacity={ width ? 1 : 0 }>
<g transform={ `translate(${ chartMargin?.left || 0 },${ chartMargin?.top || 0 })` }>
<ChartGridLine
......
......@@ -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);
......
......@@ -13,13 +13,15 @@ import ColorModeToggler from './ColorModeToggler';
type Props = {
isHomePage?: boolean;
hideOnScrollDown?: boolean;
renderSearchBar?: () => React.ReactNode;
}
const Header = ({ hideOnScrollDown, isHomePage }: Props) => {
const Header = ({ isHomePage, renderSearchBar }: Props) => {
const bgColor = useColorModeValue('white', 'black');
const scrollDirection = useScrollDirection();
const searchBar = renderSearchBar ? renderSearchBar() : <SearchBar/>;
return (
<>
<Box bgColor={ bgColor } display={{ base: 'block', lg: 'none' }}>
......@@ -37,13 +39,13 @@ const Header = ({ hideOnScrollDown, isHomePage }: Props) => {
zIndex="sticky2"
transitionProperty="box-shadow"
transitionDuration="slow"
boxShadow={ !hideOnScrollDown && scrollDirection === 'down' ? 'md' : 'none' }
boxShadow={ scrollDirection === 'down' ? 'md' : 'none' }
>
<Burger/>
<NetworkLogo/>
<ProfileMenuMobile/>
</Flex>
{ !isHomePage && <SearchBar withShadow={ !hideOnScrollDown }/> }
{ !isHomePage && searchBar }
</Box>
<Box
paddingX={ 12 }
......@@ -61,7 +63,7 @@ const Header = ({ hideOnScrollDown, isHomePage }: Props) => {
paddingBottom="52px"
>
<Box width="100%">
<SearchBar/>
{ searchBar }
</Box>
<ColorModeToggler/>
<ProfileMenuDesktop/>
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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