Commit d7252d4c authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into address-metamask

parents d04e2d33 6f6e325f
......@@ -43,6 +43,7 @@ NEXT_PUBLIC_AD_ADBUTLER_ON=__PLACEHOLDER_FORNEXT_PUBLIC_AD_ADBUTLER_ON__
# api config
NEXT_PUBLIC_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_HOST__
NEXT_PUBLIC_API_BASE_PATH=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_BASE_PATH__
NEXT_PUBLIC_STATS_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_STATS_API_HOST__
# external services config
NEXT_PUBLIC_SENTRY_DSN=__PLACEHOLDER_FOR_NEXT_PUBLIC_SENTRY_DSN__
......
......@@ -94,6 +94,7 @@ The app instance could be customized by passing following variables to NodeJS en
| --- | --- | --- | --- |
| NEXT_PUBLIC_API_HOST | `string` *(optional)* | By default the API endpoint base URL will be set as `https://blockscout.com`. If it is not the case, pass the API host in this variable | `my-host.com` |
| NEXT_PUBLIC_API_BASE_PATH | `string` *(optional)* | Base path for API endpoint url | `/poa/core` |
| NEXT_PUBLIC_STATS_API_HOST | `string` *(optional)* | Pass the Stats API host in this variable | `https://my-host.com` |
### Featured network configuration properties
......
......@@ -94,6 +94,9 @@ const config = Object.freeze({
socket: apiHost ? `wss://${ apiHost }` : 'wss://blockscout.com',
basePath: stripTrailingSlash(getEnvValue(process.env.NEXT_PUBLIC_API_BASE_PATH) || ''),
},
statsApi: {
endpoint: getEnvValue(process.env.NEXT_PUBLIC_STATS_API_HOST),
},
homepage: {
charts: parseEnvJson<Array<ChainIndicatorId>>(getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_CHARTS)) || [],
plateGradient: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT) ||
......
......@@ -12,3 +12,4 @@ NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
......@@ -20,3 +20,4 @@ NEXT_PUBLIC_MARKETPLACE_APP_LIST=[{'author': 'Blockscout','id':'token-approval-t
# api config
NEXT_PUBLIC_API_HOST=blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
......@@ -10,3 +10,4 @@ NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
......@@ -5,6 +5,7 @@ NEXT_PUBLIC_FEATURED_NETWORKS=[{'title':'Gnosis Chain','url':'https://blockscout
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address'}}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cup']
NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT=radial-gradient(at 12% 37%, hsla(324,73%,67%,1) 0px, transparent 50%), radial-gradient(at 62% 14%, hsla(256,87%,73%,1) 0px, transparent 50%), radial-gradient(at 84% 80%, hsla(128,75%,73%,1) 0px, transparent 50%), radial-gradient(at 57% 46%, hsla(285,63%,72%,1) 0px, transparent 50%), radial-gradient(at 37% 30%, hsla(174,70%,61%,1) 0px, transparent 50%), radial-gradient(at 15% 86%, hsla(350,65%,70%,1) 0px, transparent 50%), radial-gradient(at 67% 57%, hsla(14,95%,76%,1) 0px, transparent 50%)
#NEXT_PUBLIC_NETWORK_LOGO=https://placekitten.com/300/60
#NEXT_PUBLIC_NETWORK_SMALL_LOGO=https://placekitten.com/300/300
# network config
......
......@@ -14,3 +14,4 @@ NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
......@@ -455,6 +455,7 @@ frontend:
- "/blocks"
- "/block"
- "/address"
- "/stats"
resources:
limits:
memory:
......@@ -518,6 +519,8 @@ frontend:
_default: unknown
NEXT_PUBLIC_API_HOST:
_default: blockscout.com/eth/goerli
NEXT_PUBLIC_STATS_API_HOST:
_default: https://stats-test.aws-k8s.blockscout.com/
NEXT_PUBLIC_APP_HOST:
_default: blockscout.com/eth/goerli
NEXT_PUBLIC_LOGOUT_URL:
......
......@@ -318,6 +318,7 @@ frontend:
- "/block"
- "/login"
- "/address"
- "/stats"
resources:
limits:
memory:
......@@ -373,6 +374,8 @@ frontend:
NEXT_PUBLIC_API_HOST:
_default: blockscout.com
review: blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_STATS_API_HOST:
_default: https://stats-test.aws-k8s.blockscout.com/
NEXT_PUBLIC_AUTH_URL:
_default: https://blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_API_BASE_PATH:
......
<svg viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.822 15.052h-6.9v2.731h3.19l-.01 3.433c-.123.132-.269.24-.43.319-.21.108-.43.197-.656.267a5.653 5.653 0 0 1-1.656.247 3.012 3.012 0 0 1-2.715-1.424 7.746 7.746 0 0 1-.9-4.064v-2.6a9.44 9.44 0 0 1 .265-2.36 5.76 5.76 0 0 1 .715-1.71c.267-.427.63-.785 1.06-1.047.4-.237.858-.36 1.323-.357a2.922 2.922 0 0 1 2.191.735c.526.615.84 1.383.895 2.191h3.627a8.472 8.472 0 0 0-.615-2.458 5.315 5.315 0 0 0-1.304-1.857 5.626 5.626 0 0 0-2.047-1.164 9.016 9.016 0 0 0-2.84-.4 6.71 6.71 0 0 0-2.774.572A6.354 6.354 0 0 0 2.016 7.77a7.927 7.927 0 0 0-1.483 2.659 10.954 10.954 0 0 0-.532 3.557v2.575a11.475 11.475 0 0 0 .51 3.557c.3.97.793 1.871 1.45 2.646a6.251 6.251 0 0 0 2.264 1.65 7.406 7.406 0 0 0 2.966.573c.765.005 1.529-.07 2.278-.221a10.497 10.497 0 0 0 1.914-.58 7.636 7.636 0 0 0 1.477-.8c.362-.247.691-.54.98-.87l-.018-7.464Zm2.66 2.783c-.01.97.143 1.935.451 2.855.281.837.73 1.61 1.317 2.269a5.985 5.985 0 0 0 2.133 1.5 7.278 7.278 0 0 0 2.88.54 7.194 7.194 0 0 0 2.86-.54 5.935 5.935 0 0 0 2.118-1.5 6.602 6.602 0 0 0 1.311-2.27c.308-.92.46-1.884.45-2.854v-.274a8.673 8.673 0 0 0-.45-2.84 6.532 6.532 0 0 0-1.317-2.27 6.078 6.078 0 0 0-2.125-1.508 7.812 7.812 0 0 0-5.74 0 6.082 6.082 0 0 0-2.12 1.508 6.516 6.516 0 0 0-1.317 2.27 8.647 8.647 0 0 0-.45 2.84v.274Zm3.681-.273a7.394 7.394 0 0 1 .174-1.625c.1-.478.284-.936.541-1.352a2.633 2.633 0 0 1 2.357-1.26c.495-.016.984.1 1.418.337.387.224.712.542.947.923.253.417.435.874.535 1.352a7.4 7.4 0 0 1 .173 1.625v.273a7.613 7.613 0 0 1-.172 1.659c-.1.478-.281.935-.537 1.352a2.622 2.622 0 0 1-2.337 1.255 2.818 2.818 0 0 1-1.424-.338 2.759 2.759 0 0 1-.96-.917 4.143 4.143 0 0 1-.541-1.352 7.6 7.6 0 0 1-.174-1.659v-.274.001Zm-1.747-10.3c.086.196.212.37.37.514.167.147.36.262.57.338.472.164.985.164 1.456 0 .21-.076.404-.19.57-.338.16-.143.285-.318.37-.514a1.594 1.594 0 0 0 0-1.274 1.562 1.562 0 0 0-.37-.52 1.7 1.7 0 0 0-.57-.344 2.206 2.206 0 0 0-1.456 0 1.7 1.7 0 0 0-.57.345 1.562 1.562 0 0 0-.502 1.157c0 .219.045.436.133.637v-.001Zm6.357.013c.086.197.212.374.37.52a1.7 1.7 0 0 0 .57.345c.47.165.984.165 1.456 0a1.7 1.7 0 0 0 .57-.345c.157-.146.283-.323.37-.52.089-.205.134-.427.132-.65a1.561 1.561 0 0 0-.503-1.151 1.761 1.761 0 0 0-.57-.338 2.206 2.206 0 0 0-1.456 0c-.21.075-.403.19-.57.338a1.546 1.546 0 0 0-.503 1.151c-.002.224.043.446.134.65Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.822 15.052h-6.9v2.731h3.19l-.01 3.433c-.123.132-.269.24-.43.319-.21.108-.43.197-.656.267a5.653 5.653 0 0 1-1.656.247 3.012 3.012 0 0 1-2.715-1.424 7.746 7.746 0 0 1-.9-4.064v-2.6a9.44 9.44 0 0 1 .265-2.36 5.76 5.76 0 0 1 .715-1.71c.267-.427.63-.785 1.06-1.047.4-.237.858-.36 1.323-.357a2.922 2.922 0 0 1 2.191.735c.526.615.84 1.383.895 2.191h3.627a8.472 8.472 0 0 0-.615-2.458 5.315 5.315 0 0 0-1.304-1.857 5.626 5.626 0 0 0-2.047-1.164 9.016 9.016 0 0 0-2.84-.4 6.71 6.71 0 0 0-2.774.572A6.354 6.354 0 0 0 2.016 7.77a7.927 7.927 0 0 0-1.483 2.659 10.954 10.954 0 0 0-.532 3.557v2.575a11.475 11.475 0 0 0 .51 3.557c.3.97.793 1.871 1.45 2.646a6.251 6.251 0 0 0 2.264 1.65 7.406 7.406 0 0 0 2.966.573c.765.005 1.529-.07 2.278-.221a10.497 10.497 0 0 0 1.914-.58 7.636 7.636 0 0 0 1.477-.8 5.2 5.2 0 0 0 .98-.87l-.018-7.464Zm2.66 2.783c-.01.97.143 1.935.451 2.855.281.837.73 1.61 1.317 2.269a5.985 5.985 0 0 0 2.133 1.5 7.278 7.278 0 0 0 2.88.54 7.194 7.194 0 0 0 2.86-.54 5.935 5.935 0 0 0 2.118-1.5 6.602 6.602 0 0 0 1.311-2.27c.308-.92.46-1.884.45-2.854v-.274a8.673 8.673 0 0 0-.45-2.84 6.532 6.532 0 0 0-1.317-2.27 6.078 6.078 0 0 0-2.125-1.508 7.812 7.812 0 0 0-5.74 0 6.082 6.082 0 0 0-2.12 1.508 6.516 6.516 0 0 0-1.317 2.27 8.647 8.647 0 0 0-.45 2.84v.274Zm3.681-.273a7.394 7.394 0 0 1 .174-1.625c.1-.478.284-.936.541-1.352a2.633 2.633 0 0 1 2.357-1.26c.495-.016.984.1 1.418.337.387.224.712.542.947.923.253.417.435.874.535 1.352a7.4 7.4 0 0 1 .173 1.625v.273a7.613 7.613 0 0 1-.172 1.659c-.1.478-.281.935-.537 1.352a2.622 2.622 0 0 1-2.337 1.255 2.818 2.818 0 0 1-1.424-.338 2.759 2.759 0 0 1-.96-.917 4.143 4.143 0 0 1-.541-1.352 7.6 7.6 0 0 1-.174-1.659v-.274.001Zm-1.747-10.3c.086.196.212.37.37.514.167.147.36.262.57.338.472.164.985.164 1.456 0 .21-.076.404-.19.57-.338.16-.143.285-.318.37-.514a1.594 1.594 0 0 0 0-1.274 1.562 1.562 0 0 0-.37-.52 1.7 1.7 0 0 0-.57-.344 2.206 2.206 0 0 0-1.456 0 1.7 1.7 0 0 0-.57.345 1.562 1.562 0 0 0-.502 1.157c0 .219.045.436.133.637v-.001Zm6.357.013c.086.197.212.374.37.52a1.7 1.7 0 0 0 .57.345c.47.165.984.165 1.456 0a1.7 1.7 0 0 0 .57-.345c.157-.146.283-.323.37-.52.089-.205.134-.427.132-.65a1.561 1.561 0 0 0-.503-1.151 1.761 1.761 0 0 0-.57-.338 2.206 2.206 0 0 0-1.456 0c-.21.075-.403.19-.57.338a1.546 1.546 0 0 0-.503 1.151c-.002.224.043.446.134.65Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.485 7.012h1.446a.918.918 0 1 1 0 1.84H4.253a.919.919 0 0 1-.92-.92V4.254a.919.919 0 1 1 1.84 0v1.47l.505-.505a6.436 6.436 0 0 1 9.103 0 6.436 6.436 0 0 1 0 9.103 6.436 6.436 0 0 1-9.103 0 .92.92 0 0 1 1.302-1.301 4.597 4.597 0 1 0 0-6.503l-.495.494Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.615 5.192c0-.934.758-1.692 1.693-1.692h2.538c.935 0 1.692.758 1.692 1.692v18.616c0 .934-.757 1.692-1.692 1.692h-2.538a1.692 1.692 0 0 1-1.693-1.692V5.192Zm4.231.339a.339.339 0 0 0-.338-.339h-1.862a.339.339 0 0 0-.338.339v17.938c0 .187.151.339.338.339h1.862a.339.339 0 0 0 .338-.339V5.531ZM4 19.577c0-.935.758-1.692 1.692-1.692h2.539c.934 0 1.692.757 1.692 1.692v4.23c0 .935-.758 1.693-1.692 1.693H5.692A1.692 1.692 0 0 1 4 23.808v-4.231Zm4.23.338a.339.339 0 0 0-.338-.338H6.031a.339.339 0 0 0-.339.338v3.554a.34.34 0 0 0 .339.339h1.861a.338.338 0 0 0 .339-.339v-3.554Zm12.693-9.646c-.934 0-1.692.758-1.692 1.693v11.846c0 .934.757 1.692 1.692 1.692h2.539c.934 0 1.692-.758 1.692-1.692V11.962c0-.935-.758-1.693-1.692-1.693h-2.539Zm2.2 1.693c.187 0 .339.151.339.338v11.17a.338.338 0 0 1-.339.338h-1.861a.338.338 0 0 1-.339-.339V12.3c0-.187.152-.338.339-.338h1.861Z" fill="currentColor"/>
</svg>
......@@ -8,14 +8,17 @@ import * as cookies from 'lib/cookies';
// first arg can be only a string
// FIXME migrate to RequestInfo later if needed
export default function fetchFactory(_req: NextApiRequest) {
export default function fetchFactory(
_req: NextApiRequest,
apiEndpoint: string = appConfig.api.endpoint,
) {
return function fetch(path: string, init?: RequestInit): Promise<Response> {
const headers = {
accept: 'application/json',
'content-type': 'application/json',
cookie: `${ cookies.NAMES.API_TOKEN }=${ _req.cookies[cookies.NAMES.API_TOKEN] }`,
};
const url = new URL(path, appConfig.api.endpoint);
const url = new URL(path, apiEndpoint);
httpLogger.logger.info({
message: 'Trying to call API',
......
......@@ -6,7 +6,7 @@ import { httpLogger } from 'lib/api/logger';
type Methods = 'GET' | 'POST' | 'PUT' | 'DELETE';
export default function createHandler(getUrl: (_req: NextApiRequest) => string, allowedMethods: Array<Methods>) {
export default function createHandler(getUrl: (_req: NextApiRequest) => string, allowedMethods: Array<Methods>, apiEndpoint?: string) {
const handler = async(_req: NextApiRequest, res: NextApiResponse) => {
httpLogger(_req, res);
......@@ -18,8 +18,8 @@ export default function createHandler(getUrl: (_req: NextApiRequest) => string,
const isBodyDisallowed = _req.method === 'GET' || _req.method === 'HEAD';
const url = getUrlWithNetwork(_req, `/api${ getUrl(_req) }`);
const fetch = fetchFactory(_req);
const url = apiEndpoint ? `/api${ getUrl(_req) }` : getUrlWithNetwork(_req, `/api${ getUrl(_req) }`);
const fetch = fetchFactory(_req, apiEndpoint);
const response = await fetch(url, {
method: _req.method,
body: isBodyDisallowed ? undefined : _req.body,
......
import BigNumber from 'bignumber.js';
interface Params {
value: string;
exchangeRate?: string | null;
accuracy?: number;
accuracyUsd?: number;
decimals?: string | null;
}
export default function getCurrencyValue({ value, accuracy, accuracyUsd, decimals, exchangeRate }: Params) {
const valueCurr = BigNumber(value).div(BigNumber(10 ** Number(decimals || '18')));
const valueResult = accuracy ? valueCurr.dp(accuracy).toFormat() : valueCurr.toFormat();
let usdResult: string | undefined;
if (exchangeRate) {
const exchangeRateBn = new BigNumber(exchangeRate);
const usdBn = valueCurr.times(exchangeRateBn);
if (accuracyUsd && !usdBn.isEqualTo(0)) {
const usdBnDp = usdBn.dp(accuracyUsd);
usdResult = usdBnDp.isEqualTo(0) ? usdBn.precision(accuracyUsd).toFormat() : usdBnDp.toFormat();
} else {
usdResult = usdBn.toFormat();
}
}
return { valueStr: valueResult, usd: usdResult };
}
import { useRouter } from 'next/router';
import link from 'lib/link/link';
export default function useLoginUrl() {
const router = useRouter();
return link('auth', {}, { path: router.asPath });
}
......@@ -9,6 +9,7 @@ import blocksIcon from 'icons/block.svg';
import privateTagIcon from 'icons/privattags.svg';
import profileIcon from 'icons/profile.svg';
import publicTagIcon from 'icons/publictags.svg';
import statsIcon from 'icons/stats.svg';
import tokensIcon from 'icons/token.svg';
import transactionsIcon from 'icons/transactions.svg';
import watchlistIcon from 'icons/watchlist.svg';
......@@ -28,6 +29,7 @@ export default function useNavItems() {
{ text: 'Tokens', url: link('tokens'), icon: tokensIcon, isActive: currentRoute === 'tokens', isNewUi: false },
isMarketplaceFilled ?
{ text: 'Apps', url: link('apps'), icon: appsIcon, isActive: currentRoute === 'apps', isNewUi: true } : null,
{ text: 'Charts & stats', url: link('stats'), icon: statsIcon, isActive: currentRoute === 'stats', isNewUi: true },
// there should be custom site sections like Stats, Faucet, More, etc but never an 'other'
// examples https://explorer-edgenet.polygon.technology/ and https://explorer.celo.org/
// at this stage custom menu items is under development, we will implement it later
......
import React from 'react';
// prevent set focus on button when closing modal
export default function usePreventFocusAfterModalClosing() {
return React.useCallback((e: React.SyntheticEvent) => e.stopPropagation(), []);
}
......@@ -3,9 +3,9 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { pick, omit } from 'lodash';
import { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import { animateScroll } from 'react-scroll';
import { animateScroll, scroller } from 'react-scroll';
import { PAGINATION_FIELDS } from 'types/api/pagination';
import { PAGINATION_FIELDS, PAGINATION_FILTERS_FIELDS } from 'types/api/pagination';
import type { PaginationParams, PaginatedResponse, PaginatedQueryKeys, PaginationFilters } from 'types/api/pagination';
import useFetch from 'lib/hooks/useFetch';
......@@ -16,9 +16,17 @@ interface Params<QueryName extends PaginatedQueryKeys> {
queryIds?: Array<string>;
filters?: PaginationFilters<QueryName>;
options?: Omit<UseQueryOptions<unknown, unknown, PaginatedResponse<QueryName>>, 'queryKey' | 'queryFn'>;
scroll?: { elem: string; offset: number };
}
export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>({ queryName, filters, options, apiPath, queryIds }: Params<QueryName>) {
export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>({
queryName,
filters,
options,
apiPath,
queryIds,
scroll,
}: Params<QueryName>) {
const paginationFields = PAGINATION_FIELDS[queryName];
const queryClient = useQueryClient();
const router = useRouter();
......@@ -31,6 +39,10 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
const queryKey = [ queryName, ...(queryIds || []), { page, filters } ];
const scrollToTop = useCallback(() => {
scroll ? scroller.scrollTo(scroll.elem, { offset: scroll.offset }) : animateScroll.scrollToTop({ duration: 0 });
}, [ scroll ]);
const queryResult = useQuery<unknown, unknown, PaginatedResponse<QueryName>>(
queryKey,
async() => {
......@@ -65,10 +77,10 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true })
.then(() => {
animateScroll.scrollToTop({ duration: 0 });
scrollToTop();
setPage(prev => prev + 1);
});
}, [ data?.next_page_params, page, pageParams.length, router ]);
}, [ data?.next_page_params, page, pageParams.length, router, scrollToTop ]);
const onPrevPageClick = useCallback(() => {
// returning to the first page
......@@ -85,21 +97,44 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
router.query = nextPageQuery;
router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true })
.then(() => {
animateScroll.scrollToTop({ duration: 0 });
scrollToTop();
setPage(prev => prev - 1);
page === 2 && queryClient.clear();
});
}, [ router, page, paginationFields, pageParams, queryClient ]);
}, [ router, page, paginationFields, pageParams, queryClient, scrollToTop ]);
const resetPage = useCallback(() => {
queryClient.clear();
router.push({ pathname: router.pathname, query: omit(router.query, paginationFields, 'page') }, undefined, { shallow: true }).then(() => {
animateScroll.scrollToTop({ duration: 0 });
queryClient.removeQueries({ queryKey: [ queryName ] });
scrollToTop();
setPage(1);
setPageParams([ ]);
canGoBackwards.current = true;
});
}, [ queryClient, router, paginationFields ]);
}, [ queryClient, queryName, router, paginationFields, scrollToTop ]);
const onFilterChange = useCallback((newFilters: PaginationFilters<QueryName> | undefined) => {
const newQuery = omit(router.query, PAGINATION_FIELDS[queryName], 'page', PAGINATION_FILTERS_FIELDS[queryName]);
if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => {
if (value) {
newQuery[key] = Array.isArray(value) ? value.join(',') : (value || '');
}
});
}
router.push(
{
pathname: router.pathname,
query: newQuery,
},
undefined,
{ shallow: true },
).then(() => {
setPage(1);
setPageParams([ ]);
scrollToTop();
});
}, [ queryName, router, scrollToTop, setPageParams, setPage ]);
const hasPaginationParams = Object.keys(currPageParams).length > 0;
const nextPageParams = data?.next_page_params;
......@@ -129,5 +164,5 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
}, 0);
}, []);
return { ...queryResult, pagination };
return { ...queryResult, pagination, onFilterChange };
}
......@@ -5,7 +5,7 @@ import React from 'react';
import { QueryKeys } from 'types/client/accountQueries';
import * as cookies from 'lib/cookies';
import link from 'lib/link/link';
import useLoginUrl from 'lib/hooks/useLoginUrl';
export interface ErrorType {
error?: {
......@@ -19,6 +19,7 @@ export default function useRedirectForInvalidAuthToken() {
const state = queryClient.getQueryState<unknown, ErrorType>([ QueryKeys.profile ]);
const errorStatus = state?.error?.error?.status;
const loginUrl = useLoginUrl();
React.useEffect(() => {
if (errorStatus === 401) {
......@@ -26,9 +27,8 @@ export default function useRedirectForInvalidAuthToken() {
if (apiToken) {
Sentry.captureException(new Error('Invalid api token'), { tags: { source: 'fetch' } });
const authURL = link('auth');
window.location.assign(authURL);
window.location.assign(loginUrl);
}
}
}, [ errorStatus ]);
}, [ errorStatus, loginUrl ]);
}
......@@ -4,13 +4,18 @@ import React from 'react';
import type { RouteName } from 'lib/link/routes';
import { ROUTES } from 'lib/link/routes';
const PATH_PARAM_REGEXP = /\/:(\w+)/g;
export default function useCurrentRoute() {
const { route: nextRoute } = useRouter();
return React.useCallback((): RouteName => {
for (const routeName in ROUTES) {
const route = ROUTES[routeName as RouteName];
if (route.pattern === nextRoute) {
const formattedRoute = route.pattern.replace(PATH_PARAM_REGEXP, (_, paramName: string) => {
return `/[${ paramName }]`;
});
if (formattedRoute === nextRoute) {
return routeName as RouteName;
}
}
......
......@@ -7,6 +7,10 @@ SocketMessage.BlocksIndexStatus |
SocketMessage.TxStatusUpdate |
SocketMessage.NewTx |
SocketMessage.NewPendingTx |
SocketMessage.AddressBalance |
SocketMessage.AddressCurrentCoinBalance |
SocketMessage.AddressTokenBalance |
SocketMessage.AddressCoinBalance |
SocketMessage.Unknown;
interface SocketMessageParamsGeneric<Event extends string | undefined, Payload extends object | unknown> {
......@@ -15,6 +19,16 @@ interface SocketMessageParamsGeneric<Event extends string | undefined, Payload e
handler: (payload: Payload) => void;
}
export interface AddressCoinBalancePayload {
coin_balance: {
block_number: number;
block_timestamp?: string;
delta?: string;
transaction_hash?: string | null;
value?: string;
};
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace SocketMessage {
export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>;
......@@ -22,5 +36,10 @@ export namespace SocketMessage {
export type TxStatusUpdate = SocketMessageParamsGeneric<'collated', NewBlockSocketResponse>;
export type NewTx = SocketMessageParamsGeneric<'transaction', { transaction: number }>;
export type NewPendingTx = SocketMessageParamsGeneric<'pending_transaction', { pending_transaction: number }>;
export type AddressBalance = SocketMessageParamsGeneric<'balance', { balance: string; block_number: number; exchange_rate: string }>;
export type AddressCurrentCoinBalance =
SocketMessageParamsGeneric<'current_coin_balance', { coin_balance: string; block_number: number; exchange_rate: string }>;
export type AddressTokenBalance = SocketMessageParamsGeneric<'token_balance', { block_number: number }>;
export type AddressCoinBalance = SocketMessageParamsGeneric<'coin_balance', AddressCoinBalancePayload>;
export type Unknown = SocketMessageParamsGeneric<undefined, unknown>;
}
......@@ -21,7 +21,7 @@ export function middleware(req: NextRequest) {
const apiToken = req.cookies.get(NAMES.API_TOKEN);
if ((isAccountRoute || isProfileRoute) && !apiToken && appConfig.isAccountSupported) {
const authUrl = link('auth');
const authUrl = link('auth', undefined, { path: req.nextUrl.pathname });
return NextResponse.redirect(authUrl);
}
......
import type { AddressTokenBalance } from 'types/api/address';
export const erc20a: AddressTokenBalance = {
token: {
address: '0xb2a90505dc6680a7a695f7975d0d32EeF610f456',
decimals: '18',
exchange_rate: null,
holders: '23',
name: 'hyfi.token',
symbol: 'HyFi',
total_supply: '369000000000000000000000000',
type: 'ERC-20',
},
token_id: null,
value: '1169320000000000000000000',
};
export const erc20b: AddressTokenBalance = {
token: {
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
decimals: '6',
exchange_rate: '0.982',
holders: '17',
name: 'USD Coin',
symbol: 'USDC',
total_supply: '900000000000000000000000000',
type: 'ERC-20',
},
token_id: null,
value: '872500000000',
};
export const erc20c: AddressTokenBalance = {
token: {
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
decimals: '18',
exchange_rate: '1328.89',
holders: '17',
name: 'Ethereum',
symbol: 'ETH',
total_supply: '1000000000000000000000000',
type: 'ERC-20',
},
token_id: null,
value: '9852000000000000000000',
};
export const erc20d: AddressTokenBalance = {
token: {
address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9195',
decimals: '18',
exchange_rate: null,
holders: '102625',
name: 'Zeta',
symbol: 'ZETA',
total_supply: '2100000000000000000000000000',
type: 'ERC-20',
},
token_id: null,
value: '39000000000000000000',
};
export const erc721a: AddressTokenBalance = {
token: {
address: '0xDe7cAc71E072FCBd4453E5FB3558C2684d1F88A0',
decimals: null,
exchange_rate: null,
holders: '7',
name: 'HyFi Athena',
symbol: 'HYFI_ATHENA',
total_supply: '105',
type: 'ERC-721',
},
token_id: null,
value: '51',
};
export const erc721b: AddressTokenBalance = {
token: {
address: '0xA8d5C7beEA8C9bB57f5fBa35fB638BF45550b11F',
decimals: null,
exchange_rate: null,
holders: '2',
name: 'World Of Women Galaxy',
symbol: 'WOWG',
total_supply: null,
type: 'ERC-721',
},
token_id: null,
value: '1',
};
export const erc721c: AddressTokenBalance = {
token: {
address: '0x47646F1d7dc4Dd2Db5a41D092e2Cf966e27A4992',
decimals: null,
exchange_rate: null,
holders: '12',
name: 'Puma',
symbol: 'PUMA',
total_supply: null,
type: 'ERC-721',
},
token_id: null,
value: '5',
};
export const erc1155a: AddressTokenBalance = {
token: {
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
decimals: null,
exchange_rate: null,
holders: '22',
name: 'HyFi Membership',
symbol: 'HYFI_MEMBERSHIP',
total_supply: '482',
type: 'ERC-1155',
},
token_id: '42',
value: '24',
};
export const erc1155b: AddressTokenBalance = {
token: {
address: '0xf4b71b179132ad457f6bcae2a55efa9e4b26eefc',
decimals: null,
exchange_rate: null,
holders: '100',
name: 'WinkyVerse Collections',
symbol: 'WVC',
total_supply: '4943',
type: 'ERC-1155',
},
token_id: '100010000000001',
value: '11',
};
export const erc1155withoutName: AddressTokenBalance = {
token: {
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
decimals: null,
exchange_rate: null,
holders: '22',
name: null,
symbol: null,
total_supply: '482',
type: 'ERC-1155',
},
token_id: '64532245',
value: '42',
};
export const baseList = [
erc20a,
erc20b,
erc20c,
erc721a,
erc721b,
erc721c,
erc1155withoutName,
erc1155a,
erc1155b,
];
......@@ -29,6 +29,7 @@ export const erc20: TokenTransfer = {
name: 'ARIANEE',
symbol: 'ARIA',
type: 'ERC-20',
total_supply: '0',
},
total: {
decimals: '18',
......@@ -67,6 +68,7 @@ export const erc721: TokenTransfer = {
name: 'Arianee Smart-Asset',
symbol: 'AriaSA',
type: 'ERC-721',
total_supply: '0',
},
total: {
token_id: '875879856',
......@@ -104,6 +106,7 @@ export const erc1155: TokenTransfer = {
name: null,
symbol: null,
type: 'ERC-1155',
total_supply: '0',
},
total: {
token_id: '123',
......
import type { NextApiRequest } from 'next';
import getSearchParams from 'lib/api/getSearchParams';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
const searchParamsStr = getSearchParams(req);
return `/v2/addresses/${ req.query.id }/token-transfers${ searchParamsStr ? '?' + searchParamsStr : '' }`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import type { NextApiRequest } from 'next';
import getSearchParams from 'lib/api/getSearchParams';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
const searchParamsStr = getSearchParams(req);
return `/v2/addresses/${ req.query.id }/transactions${ searchParamsStr ? '?' + searchParamsStr : '' }`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import type { NextApiRequest } from 'next';
import appConfig from 'configs/app/config';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
const { name, from, to } = req.query;
return `/v1/charts/line?name=${ name }${ from ? `&from=${ from }&to=${ to }` : '' }`;
};
const requestHandler = handler(getUrl, [ 'GET' ], appConfig.statsApi.endpoint);
export default requestHandler;
import appConfig from 'configs/app/config';
import handler from 'lib/api/handler';
const getUrl = () => '/v1/counters';
const requestHandler = handler(getUrl, [ 'GET' ], appConfig.statsApi.endpoint);
export default requestHandler;
......@@ -2,6 +2,7 @@ import type { TestFixture, Page } from '@playwright/test';
import type { WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import type { AddressCoinBalancePayload } from 'lib/socket/types';
import type { NewBlockSocketResponse } from 'types/api/block';
type ReturnType = () => Promise<WebSocket>;
......@@ -53,6 +54,8 @@ export const joinChannel = async(socket: WebSocket, channelName: string) => {
});
};
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'coin_balance', payload: AddressCoinBalancePayload): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_balance', payload: { block_number: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'transaction', payload: { transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block', payload: NewBlockSocketResponse): void;
......
......@@ -51,15 +51,15 @@ const variantOutline = defineStyle((props) => {
const activeBg = isGrayTheme ? mode('blue.50', 'gray.600')(props) : mode(`${ c }.50`, 'gray.600')(props);
const activeColor = (() => {
if (c === 'gray') {
return mode('blue.400', 'gray.50')(props);
return mode('blue.600', 'gray.50')(props);
}
if (c === 'gray-dark') {
return mode('blue.700', 'gray.50')(props);
return mode('blue.600', 'gray.50')(props);
}
if (c === 'blue') {
return mode('blue.400', 'gray.50')(props);
return mode('blue.600', 'gray.50')(props);
}
return 'blue.400';
return 'blue.600';
})();
return {
......@@ -77,11 +77,17 @@ const variantOutline = defineStyle((props) => {
bg: props.isActive ? activeBg : 'transparent',
borderColor: props.isActive ? activeBg : 'blue.400',
color: props.isActive ? activeColor : 'blue.400',
p: {
color: 'blue.400',
},
},
_disabled: {
color,
borderColor,
},
p: {
color: 'blue.400',
},
},
_disabled: {
opacity: 0.2,
......@@ -94,6 +100,9 @@ const variantOutline = defineStyle((props) => {
color,
borderColor,
},
p: {
color: activeColor,
},
},
};
});
......
import { formAnatomy as parts } from '@chakra-ui/anatomy';
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
import type { StyleFunctionProps } from '@chakra-ui/theme-tools';
import { getColor, mode } from '@chakra-ui/theme-tools';
import { getColor } from '@chakra-ui/theme-tools';
import getDefaultFormColors from '../utils/getDefaultFormColors';
import FormLabel from './FormLabel';
......@@ -13,7 +13,7 @@ const { definePartsStyle, defineMultiStyleConfig } =
function getFloatingVariantStylesForSize(size: 'md' | 'lg', props: StyleFunctionProps) {
const { theme } = props;
const { focusColor: fc, errorColor: ec } = getDefaultFormColors(props);
const { focusPlaceholderColor, errorColor } = getDefaultFormColors(props);
const activeLabelStyles = {
...FormLabel.variants?.floating?.(props)._focusWithin,
......@@ -62,7 +62,7 @@ function getFloatingVariantStylesForSize(size: 'md' | 'lg', props: StyleFunction
label: FormLabel.sizes?.[size](props) || {},
'input:not(:placeholder-shown) + label, textarea:not(:placeholder-shown) + label': activeLabelStyles,
'input[aria-invalid=true] + label, textarea[aria-invalid=true] + label': {
color: getColor(theme, ec),
color: getColor(theme, errorColor),
},
// input styles
......@@ -78,20 +78,20 @@ function getFloatingVariantStylesForSize(size: 'md' | 'lg', props: StyleFunction
// indicator styles
'input:not(:placeholder-shown) + label .chakra-form__required-indicator, textarea:not(:placeholder-shown) + label .chakra-form__required-indicator': {
color: getColor(theme, fc),
color: getColor(theme, focusPlaceholderColor),
},
'input[aria-invalid=true] + label .chakra-form__required-indicator, textarea[aria-invalid=true] + label .chakra-form__required-indicator': {
color: getColor(theme, ec),
color: getColor(theme, errorColor),
},
},
};
}
const baseStyle = definePartsStyle((props) => {
const baseStyle = definePartsStyle(() => {
return {
requiredIndicator: {
marginStart: 0,
color: mode('gray.500', 'whiteAlpha.400')(props),
color: 'gray.500',
},
};
});
......
......@@ -19,7 +19,7 @@ const baseStyle = defineStyle({
const variantFloating = defineStyle((props) => {
const { theme, backgroundColor } = props;
const { focusColor: fc } = getDefaultFormColors(props);
const { focusPlaceholderColor } = getDefaultFormColors(props);
const bc = backgroundColor || mode('white', 'black')(props);
return {
......@@ -40,7 +40,7 @@ const variantFloating = defineStyle((props) => {
textOverflow: 'ellipsis',
_focusWithin: {
backgroundColor: bc,
color: getColor(theme, fc),
color: getColor(theme, focusPlaceholderColor),
fontSize: 'xs',
lineHeight: '16px',
borderTopRightRadius: 'none',
......
......@@ -26,6 +26,10 @@ const baseStyleContent = defineStyle((props) => {
bg: $popperBg.reference,
[$arrowBg.variable]: $popperBg.reference,
[$arrowShadowColor.variable]: `colors.${ shadowColor }`,
_dark: {
[$popperBg.variable]: `colors.gray.900`,
[$arrowShadowColor.variable]: `colors.whiteAlpha.300`,
},
width: 'xs',
border: 'none',
borderColor: 'inherit',
......
......@@ -4,7 +4,8 @@ import { mode } from '@chakra-ui/theme-tools';
export default function getDefaultFormColors(props: StyleFunctionProps) {
const { focusBorderColor: fc, errorBorderColor: ec, filledBorderColor: flc } = props;
return {
focusColor: fc || mode('brand.700', 'brand.300')(props),
focusBorderColor: fc || mode('blue.500', 'blue.300')(props),
focusPlaceholderColor: fc || 'gray.500',
errorColor: ec || mode('red.400', 'red.300')(props),
filledColor: flc || mode('gray.300', 'gray.600')(props),
};
......
......@@ -6,7 +6,7 @@ import getDefaultTransitionProps from './getDefaultTransitionProps';
export default function getOutlinedFieldStyles(props: StyleFunctionProps) {
const { theme, borderColor } = props;
const { focusColor: fc, errorColor: ec } = getDefaultFormColors(props);
const { focusBorderColor, errorColor } = getDefaultFormColors(props);
const transitionProps = getDefaultTransitionProps();
return {
......@@ -32,12 +32,12 @@ export default function getOutlinedFieldStyles(props: StyleFunctionProps) {
},
},
_invalid: {
borderColor: getColor(theme, ec),
borderColor: getColor(theme, errorColor),
boxShadow: `none`,
},
_focusVisible: {
zIndex: 1,
borderColor: getColor(theme, fc),
borderColor: getColor(theme, focusBorderColor),
boxShadow: 'md',
},
_placeholder: {
......
import type { AddressParam } from './addressParams';
export interface AddressTag {
address_hash: string;
name: string;
......@@ -63,6 +64,7 @@ export interface WatchlistAddress {
notification_settings: NotificationSettings;
notification_methods: NotificationMethods;
id: string;
address?: AddressParam;
}
export interface WatchlistAddressNew {
......
import type { Transaction } from 'types/api/transaction';
import type { AddressTag, WatchlistName } from './addressParams';
import type { TokenInfo } from './tokenInfo';
import type { TokenInfo, TokenType } from './tokenInfo';
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
export interface Address {
block_number_balance_updated_at: number | null;
......@@ -20,10 +23,10 @@ export interface Address {
}
export interface AddressCounters {
transaction_count: string;
token_transfer_count: string;
transactions_count: string;
token_transfers_count: string;
gas_usage_count: string;
validation_count: string | null;
validations_count: string | null;
}
export interface AddressTokenBalance {
......@@ -31,3 +34,28 @@ export interface AddressTokenBalance {
token_id: string | null;
value: string;
}
export interface AddressTransactionsResponse {
items: Array<Transaction>;
next_page_params: {
block_number: number;
index: number;
items_count: number;
} | null;
}
type AddressFromToFilter = 'from' | 'to' | undefined;
export type AddressTxsFilters = {
filter: AddressFromToFilter;
}
export interface AddressTokenTransferResponse {
items: Array<TokenTransfer>;
next_page_params: TokenTransferPagination | null;
}
export type AddressTokenTransferFilters = {
filter: AddressFromToFilter;
type: TokenType;
}
import type { AddressTransactionsResponse, AddressTokenTransferResponse, AddressTxsFilters, AddressTokenTransferFilters } from 'types/api/address';
import type { BlocksResponse, BlockTransactionsResponse, BlockFilters } from 'types/api/block';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { LogsResponse } from 'types/api/log';
......@@ -8,6 +9,8 @@ import { QueryKeys } from 'types/client/queries';
import type { KeysOfObjectOrNull } from 'types/utils/KeysOfObjectOrNull';
export type PaginatedQueryKeys =
QueryKeys.addressTxs |
QueryKeys.addressTokenTransfers |
QueryKeys.blocks |
QueryKeys.blocksReorgs |
QueryKeys.blocksUncles |
......@@ -19,21 +22,25 @@ export type PaginatedQueryKeys =
QueryKeys.txTokenTransfers;
export type PaginatedResponse<Q extends PaginatedQueryKeys> =
Q extends (QueryKeys.blocks | QueryKeys.blocksReorgs | QueryKeys.blocksUncles) ? BlocksResponse :
Q extends QueryKeys.blockTxs ? BlockTransactionsResponse :
Q extends QueryKeys.txsValidate ? TransactionsResponseValidated :
Q extends QueryKeys.txsPending ? TransactionsResponsePending :
Q extends QueryKeys.txInternals ? InternalTransactionsResponse :
Q extends QueryKeys.txLogs ? LogsResponse :
Q extends QueryKeys.txTokenTransfers ? TokenTransferResponse :
never
Q extends QueryKeys.addressTxs ? AddressTransactionsResponse :
Q extends QueryKeys.addressTokenTransfers ? AddressTokenTransferResponse :
Q extends (QueryKeys.blocks | QueryKeys.blocksReorgs | QueryKeys.blocksUncles) ? BlocksResponse :
Q extends QueryKeys.blockTxs ? BlockTransactionsResponse :
Q extends QueryKeys.txsValidate ? TransactionsResponseValidated :
Q extends QueryKeys.txsPending ? TransactionsResponsePending :
Q extends QueryKeys.txInternals ? InternalTransactionsResponse :
Q extends QueryKeys.txLogs ? LogsResponse :
Q extends QueryKeys.txTokenTransfers ? TokenTransferResponse :
never
export type PaginationFilters<Q extends PaginatedQueryKeys> =
Q extends QueryKeys.blocks ? BlockFilters :
Q extends QueryKeys.txsValidate ? TTxsFilters :
Q extends QueryKeys.txsPending ? TTxsFilters :
Q extends QueryKeys.txTokenTransfers ? TokenTransferFilters :
never
Q extends QueryKeys.addressTxs ? AddressTxsFilters :
Q extends QueryKeys.addressTokenTransfers ? AddressTokenTransferFilters :
Q extends QueryKeys.blocks ? BlockFilters :
Q extends QueryKeys.txsValidate ? TTxsFilters :
Q extends QueryKeys.txsPending ? TTxsFilters :
Q extends QueryKeys.txTokenTransfers ? TokenTransferFilters :
never
export type PaginationParams<Q extends PaginatedQueryKeys> = PaginatedResponse<Q>['next_page_params'];
......@@ -42,6 +49,8 @@ type PaginationFields = {
}
export const PAGINATION_FIELDS: PaginationFields = {
[QueryKeys.addressTxs]: [ 'block_number', 'items_count', 'index' ],
[QueryKeys.addressTokenTransfers]: [ 'block_number', 'items_count', 'index', 'transaction_hash' ],
[QueryKeys.blocks]: [ 'block_number', 'items_count' ],
[QueryKeys.blocksReorgs]: [ 'block_number', 'items_count' ],
[QueryKeys.blocksUncles]: [ 'block_number', 'items_count' ],
......@@ -52,3 +61,21 @@ export const PAGINATION_FIELDS: PaginationFields = {
[QueryKeys.txTokenTransfers]: [ 'block_number', 'items_count', 'transaction_hash', 'index' ],
[QueryKeys.txLogs]: [ 'items_count', 'transaction_hash', 'index' ],
};
type PaginationFiltersFields = {
[K in PaginatedQueryKeys]: Array<KeysOfObjectOrNull<PaginationFilters<K>>>
}
export const PAGINATION_FILTERS_FIELDS: PaginationFiltersFields = {
[QueryKeys.addressTxs]: [ 'filter' ],
[QueryKeys.addressTokenTransfers]: [ 'filter', 'type' ],
[QueryKeys.blocks]: [ 'type' ],
[QueryKeys.txsValidate]: [ 'filter', 'type', 'method' ],
[QueryKeys.txsPending]: [ 'filter', 'type', 'method' ],
[QueryKeys.txTokenTransfers]: [ 'type' ],
[QueryKeys.blocksReorgs]: [],
[QueryKeys.blocksUncles]: [],
[QueryKeys.blockTxs]: [],
[QueryKeys.txInternals]: [],
[QueryKeys.txLogs]: [],
};
......@@ -20,5 +20,12 @@ export type GasPrices = {
}
export type Stats = {
total_blocks: string;
totalBlocksAllTime: string;
}
export type Charts = {
'chart': Array<{
date: string;
value: string;
}>;
}
......@@ -8,6 +8,7 @@ export interface TokenInfo {
decimals: string | null;
holders: string | null;
exchange_rate: string | null;
total_supply: string | null;
}
export type TokenInfoGeneric<Type extends TokenType> = Omit<TokenInfo, 'type'> & { type: Type };
......@@ -38,14 +38,16 @@ interface TokenTransferBase {
to: AddressParam;
}
export type TokenTransferPagination = {
block_number: number;
index: number;
items_count: number;
transaction_hash: string;
}
export interface TokenTransferResponse {
items: Array<TokenTransfer>;
next_page_params: {
block_number: number;
index: number;
items_count: number;
transaction_hash: string;
} | null;
next_page_params: TokenTransferPagination | null;
}
export interface TokenTransferFilters {
......
import type { AddressParam } from './addressParams';
export type TransactionReward = {
types: Array<string>;
emission_reward: string;
block_hash: string;
from: AddressParam;
to: AddressParam;
}
......@@ -5,6 +5,7 @@ export enum QueryKeys {
txsPending = 'txs-pending',
homeStats='homeStats',
stats='stats',
charts='stats',
tx = 'tx',
txInternals = 'tx-internals',
txLogs = 'tx-logs',
......@@ -23,4 +24,6 @@ export enum QueryKeys {
address='address',
addressCounters='address-counters',
addressTokenBalances='address-token-balances',
addressTxs='addressTxs',
addressTokenTransfers='addressTokenTransfers',
}
......@@ -23,10 +23,4 @@ export type StatsChart = {
id: string;
title: string;
description: string;
apiMethodURL: string;
}
export interface ModalChart {
id: string;
title: string;
}
import { Box, Flex, Text, Icon, Button, Grid, Select } from '@chakra-ui/react';
import { Box, Flex, Text, Icon, Grid, Link } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import BigNumber from 'bignumber.js';
......@@ -9,18 +9,25 @@ import type { Address as TAddress, AddressCounters, AddressTokenBalance } from '
import { QueryKeys } from 'types/client/queries';
import appConfig from 'configs/app/config';
import qrCodeIcon from 'icons/qr_code.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import walletIcon from 'icons/wallet.svg';
import blockIcon from 'icons/block.svg';
import useFetch from 'lib/hooks/useFetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import link from 'lib/link/link';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import ExternalLink from 'ui/shared/ExternalLink';
import HashStringShorten from 'ui/shared/HashStringShorten';
import AddressAddToMetaMask from './details/AddressAddToMetaMask';
import AddressBalance from './details/AddressBalance';
import AddressDetailsSkeleton from './details/AddressDetailsSkeleton';
import AddressFavoriteButton from './details/AddressFavoriteButton';
import AddressNameInfo from './details/AddressNameInfo';
import AddressQrCode from './details/AddressQrCode';
import TokenSelect from './tokenSelect/TokenSelect';
interface Props {
addressQuery: UseQueryResult<TAddress>;
......@@ -48,14 +55,15 @@ const AddressDetails = ({ addressQuery }: Props) => {
);
if (countersQuery.isLoading || addressQuery.isLoading || tokenBalancesQuery.isLoading) {
return <Box>loading</Box>;
return <AddressDetailsSkeleton/>;
}
if (countersQuery.isError || addressQuery.isError || tokenBalancesQuery.isError) {
return <Box>error</Box>;
return <DataFetchAlert/>;
}
const explorers = appConfig.network.explorers.filter(({ paths }) => paths.address);
const validationsCount = Number(countersQuery.data.validations_count);
return (
<Box>
......@@ -66,16 +74,12 @@ const AddressDetails = ({ addressQuery }: Props) => {
</Text>
<CopyToClipboard text={ addressQuery.data.hash }/>
{ addressQuery.data.is_contract && addressQuery.data.token && <AddressAddToMetaMask ml={ 2 } token={ addressQuery.data.token }/> }
<Button variant="outline" size="sm" ml={ 3 }>
<Icon as={ starOutlineIcon } boxSize={ 5 }/>
</Button>
<Button variant="outline" size="sm" ml={ 2 }>
<Icon as={ qrCodeIcon } boxSize={ 5 }/>
</Button>
<AddressFavoriteButton hash={ addressQuery.data.hash } isAdded={ Boolean(addressQuery.data.watchlist_names?.length) } ml={ 3 }/>
<AddressQrCode hash={ addressQuery.data.hash } ml={ 2 }/>
</Flex>
{ explorers.length > 0 && (
<Flex mt={ 8 } columnGap={ 4 } flexWrap="wrap">
<Text>Verify with other explorers</Text>
<Text fontSize="sm">Verify with other explorers</Text>
{ explorers.map((explorer) => {
const url = new URL(explorer.paths.tx + '/' + router.query.id, explorer.baseUrl);
return <ExternalLink key={ explorer.baseUrl } title={ explorer.title } href={ url.toString() }/>;
......@@ -85,46 +89,40 @@ const AddressDetails = ({ addressQuery }: Props) => {
<Grid
mt={ 8 }
columnGap={ 8 }
rowGap={{ base: 3, lg: 3 }}
rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }} overflow="hidden"
>
<AddressNameInfo data={ addressQuery.data }/>
{ addressQuery.data.is_contract && addressQuery.data.creation_tx_hash && addressQuery.data.creator_address_hash && (
<DetailsInfoItem
title="Creator"
hint="Transaction and address of creation."
>
<AddressLink hash={ addressQuery.data.creator_address_hash } truncation="constant"/>
<Text whiteSpace="pre"> at </Text>
<AddressLink hash={ addressQuery.data.creation_tx_hash } truncation="constant"/>
</DetailsInfoItem>
) }
<AddressBalance data={ addressQuery.data }/>
<DetailsInfoItem
title="Tokens"
hint="All tokens in the account and total value."
alignSelf="center"
py={ 0 }
>
{ tokenBalancesQuery.data.length > 0 ? (
<>
{ /* TODO will be fixed later when we implement select with custom menu */ }
<Select
size="sm"
borderRadius="base"
focusBorderColor="none"
display="inline-block"
w="auto"
>
{ tokenBalancesQuery.data.map((token) =>
<option key={ token.token.address } value={ token.token.address }>{ token.token.symbol }</option>) }
</Select>
<Button variant="outline" size="sm" ml={ 3 }>
<Icon as={ walletIcon } boxSize={ 5 }/>
</Button>
</>
) : (
'-'
) }
<TokenSelect/>
</DetailsInfoItem>
<DetailsInfoItem
title="Transactions"
hint="Number of transactions related to this address."
>
{ Number(countersQuery.data.transaction_count).toLocaleString() }
{ Number(countersQuery.data.transactions_count).toLocaleString() }
</DetailsInfoItem>
<DetailsInfoItem
title="Transfers"
hint="Number of transfers to/from this address."
>
{ Number(countersQuery.data.token_transfer_count).toLocaleString() }
{ Number(countersQuery.data.token_transfers_count).toLocaleString() }
</DetailsInfoItem>
<DetailsInfoItem
title="Gas used"
......@@ -132,12 +130,29 @@ const AddressDetails = ({ addressQuery }: Props) => {
>
{ BigNumber(countersQuery.data.gas_usage_count).toFormat() }
</DetailsInfoItem>
{ countersQuery.data.validation_count && (
{ !Object.is(validationsCount, NaN) && validationsCount > 0 && (
<DetailsInfoItem
title="Blocks validated"
hint="Number of blocks validated by this validator."
>
{ Number(countersQuery.data.validation_count).toLocaleString() }
{ validationsCount.toLocaleString() }
</DetailsInfoItem>
) }
{ addressQuery.data.block_number_balance_updated_at && (
<DetailsInfoItem
title="Last balance update"
hint="Block number in which the address was updated."
alignSelf="center"
py={{ base: '2px', lg: 1 }}
>
<Link
href={ link('block', { id: String(addressQuery.data.block_number_balance_updated_at) }) }
display="flex"
alignItems="center"
>
<Icon as={ blockIcon } boxSize={ 6 } mr={ 2 }/>
{ addressQuery.data.block_number_balance_updated_at }
</Link>
</DetailsInfoItem>
) }
</Grid>
......
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { base as txMock } from 'mocks/txs/tx';
import TestApp from 'playwright/TestApp';
import AddressTxs from './AddressTxs';
const API_URL = '/node-api/addresses/0xd789a607CEac2f0E14867de4EB15b15C9FFB5859/transactions';
const hooksConfig = {
router: {
query: { id: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859' },
},
};
test('address txs +@mobile +@desktop-xl', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ items: [ txMock, txMock ], next_page_params: { block: 1 } }),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTxs/>
</TestApp>,
{ hooksConfig },
);
await page.waitForResponse(API_URL),
await expect(component).toHaveScreenshot();
});
import { useRouter } from 'next/router';
import React from 'react';
import { Element } from 'react-scroll';
import { QueryKeys } from 'types/client/queries';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import ActionBar from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/Pagination';
import TxsContent from 'ui/txs/TxsContent';
import AddressTxsFilter from './AddressTxsFilter';
const FILTER_VALUES = [ 'from', 'to' ] as const;
type FilterType = typeof FILTER_VALUES[number];
const getFilterValue = (val: string | Array<string> | undefined): FilterType | undefined => {
if (typeof val === 'string' && FILTER_VALUES.includes(val as FilterType)) {
return val as FilterType;
}
};
const SCROLL_ELEM = 'address-txs';
const SCROLL_OFFSET = -100;
const AddressTxs = () => {
const router = useRouter();
const isMobile = useIsMobile();
const [ filterValue, setFilterValue ] = React.useState<'from' | 'to' | undefined>(getFilterValue(router.query.filter));
const addressTxsQuery = useQueryWithPages({
apiPath: `/node-api/addresses/${ router.query.id }/transactions`,
queryName: QueryKeys.addressTxs,
filters: { filter: filterValue },
scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET },
});
const handleFilterChange = React.useCallback((val: string | Array<string>) => {
const newVal = getFilterValue(val);
setFilterValue(newVal);
addressTxsQuery.onFilterChange({ filter: newVal });
}, [ addressTxsQuery ]);
const isPaginatorHidden =
!addressTxsQuery.isLoading &&
!addressTxsQuery.isError &&
addressTxsQuery.pagination.page === 1 &&
!addressTxsQuery.pagination.hasNextPage;
const filter = (
<AddressTxsFilter
defaultFilter={ filterValue }
onFilterChange={ handleFilterChange }
isActive={ Boolean(filterValue) }
/>
);
return (
<Element name={ SCROLL_ELEM }>
{ !isMobile && (
<ActionBar mt={ -6 }>
{ filter }
{ !isPaginatorHidden && <Pagination { ...addressTxsQuery.pagination }/> }
</ActionBar>
) }
<TxsContent
filter={ filter }
query={ addressTxsQuery }
showSocketInfo={ false }
currentAddress={ typeof router.query.id === 'string' ? router.query.id : undefined }
/>
</Element>
);
};
export default AddressTxs;
import {
Menu,
MenuButton,
MenuList,
MenuOptionGroup,
MenuItemOption,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import FilterButton from 'ui/shared/FilterButton';
interface Props {
isActive: boolean;
defaultFilter: 'from' | 'to' | undefined;
onFilterChange: (nextValue: string | Array<string>) => void;
}
const AddressTxsFilter = ({ onFilterChange, defaultFilter, isActive }: Props) => {
const { isOpen, onToggle } = useDisclosure();
return (
<Menu>
<MenuButton>
<FilterButton
isActive={ isOpen || isActive }
onClick={ onToggle }
/>
</MenuButton>
<MenuList zIndex={ 2 }>
<MenuOptionGroup defaultValue={ defaultFilter || 'all' } title="Address" type="radio" onChange={ onFilterChange }>
<MenuItemOption value="all">All</MenuItemOption>
<MenuItemOption value="from">From</MenuItemOption>
<MenuItemOption value="to">To</MenuItemOption>
</MenuOptionGroup>
</MenuList>
</Menu>
);
};
export default React.memo(AddressTxsFilter);
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { Address } from 'types/api/address';
import { QueryKeys } from 'types/client/queries';
import appConfig from 'configs/app/config';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
data: Address;
}
const AddressBalance = ({ data }: Props) => {
const [ lastBlockNumber, setLastBlockNumber ] = React.useState<number>(data.block_number_balance_updated_at || 0);
const queryClient = useQueryClient();
const updateData = React.useCallback((balance: string, exchangeRate: string, blockNumber: number) => {
if (blockNumber < lastBlockNumber) {
return;
}
setLastBlockNumber(blockNumber);
queryClient.setQueryData([ QueryKeys.address, data.hash ], (prevData: Address | undefined) => {
if (!prevData) {
return;
}
return {
...prevData,
coin_balance: balance,
exchange_rate: exchangeRate,
block_number_balance_updated_at: blockNumber,
};
});
}, [ data.hash, lastBlockNumber, queryClient ]);
const handleNewBalanceMessage: SocketMessage.AddressBalance['handler'] = React.useCallback((payload) => {
updateData(payload.balance, payload.exchange_rate, payload.block_number);
}, [ updateData ]);
const handleNewCoinBalanceMessage: SocketMessage.AddressCurrentCoinBalance['handler'] = React.useCallback((payload) => {
updateData(payload.coin_balance, payload.exchange_rate, payload.block_number);
}, [ updateData ]);
const channel = useSocketChannel({
topic: `addresses:${ data.hash.toLowerCase() }`,
isDisabled: !data.coin_balance,
});
useSocketMessage({
channel,
event: 'balance',
handler: handleNewBalanceMessage,
});
useSocketMessage({
channel,
event: 'current_coin_balance',
handler: handleNewCoinBalanceMessage,
});
if (!data.coin_balance) {
return null;
}
return (
<DetailsInfoItem
title="Balance"
hint={ `Address balance in ${ appConfig.network.currency.symbol }. Doesn't include ERC20, ERC721 and ERC1155 tokens.` }
>
<TokenLogo
hash={ appConfig.network.currency.address }
name={ appConfig.network.currency.name }
boxSize={ 5 }
mr={ 2 }
fontSize="sm"
/>
<CurrencyValue
value={ data.coin_balance }
exchangeRate={ data.exchange_rate }
decimals={ String(appConfig.network.currency.decimals) }
currency={ appConfig.network.currency.symbol }
accuracyUsd={ 2 }
accuracy={ 8 }
/>
</DetailsInfoItem>
);
};
export default React.memo(AddressBalance);
import { Box, Flex, Grid, Skeleton, SkeletonCircle } from '@chakra-ui/react';
import React from 'react';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
const AddressDetailsSkeleton = () => {
return (
<Box>
<Flex align="center">
<SkeletonCircle boxSize={ 6 } flexShrink={ 0 }/>
<Skeleton h={ 6 } w={{ base: '100px', lg: '420px' }} ml={ 2 }/>
<Skeleton h={ 6 } w="24px" ml={ 2 } flexShrink={ 0 }/>
<Skeleton h={ 8 } w="48px" ml={ 3 } flexShrink={ 0 }/>
<Skeleton h={ 8 } w="48px" ml={ 3 } flexShrink={ 0 }/>
</Flex>
<Flex align="center" columnGap={ 4 } mt={ 8 }>
<Skeleton h={ 6 } w="200px"/>
<Skeleton h={ 6 } w="80px"/>
</Flex>
<Grid columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '150px 1fr' }} maxW="1000px" mt={ 8 }>
<DetailsSkeletonRow w="30%"/>
<DetailsSkeletonRow w="30%"/>
<DetailsSkeletonRow w="10%"/>
<DetailsSkeletonRow w="10%"/>
<DetailsSkeletonRow w="20%"/>
<DetailsSkeletonRow w="20%"/>
</Grid>
</Box>
);
};
export default AddressDetailsSkeleton;
import { Icon, chakra, Tooltip, IconButton, useDisclosure } from '@chakra-ui/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import type { TWatchlist } from 'types/client/account';
import { QueryKeys as AccountQueryKeys } from 'types/client/accountQueries';
import { QueryKeys } from 'types/client/queries';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import useFetch from 'lib/hooks/useFetch';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
interface Props {
className?: string;
hash: string;
isAdded: boolean;
}
const AddressFavoriteButton = ({ className, hash, isAdded }: Props) => {
const addModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const queryClient = useQueryClient();
const router = useRouter();
const fetch = useFetch();
const profileData = queryClient.getQueryData<UserInfo>([ AccountQueryKeys.profile ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
const watchListQuery = useQuery<unknown, unknown, TWatchlist>(
[ AccountQueryKeys.watchlist ],
async() => fetch('/node-api/account/watchlist'),
{
enabled: isAdded,
},
);
const handleClick = React.useCallback(() => {
if (!isAuth) {
window.location.assign(loginUrl);
return;
}
isAdded ? deleteModalProps.onOpen() : addModalProps.onOpen();
}, [ addModalProps, deleteModalProps, isAdded, isAuth, loginUrl ]);
const handleAddOrDeleteSuccess = React.useCallback(async() => {
await queryClient.refetchQueries({ queryKey: [ QueryKeys.address, router.query.id ] });
addModalProps.onClose();
}, [ addModalProps, queryClient, router.query.id ]);
const handleAddModalClose = React.useCallback(() => {
addModalProps.onClose();
}, [ addModalProps ]);
const handleDeleteModalClose = React.useCallback(() => {
deleteModalProps.onClose();
}, [ deleteModalProps ]);
const formData = React.useMemo(() => {
return {
address_hash: hash,
// FIXME temporary solution
// there is no endpoint in api what can return watchlist address info by its hash
// so we look up in the whole watchlist and hope we can find a necessary item
id: watchListQuery.data?.find((address) => address.address?.hash === hash)?.id || '',
};
}, [ hash, watchListQuery.data ]);
return (
<>
<Tooltip label={ `${ isAdded ? 'Remove address from Watch list' : 'Add address to Watch list' }` }>
<IconButton
isActive={ isAdded }
className={ className }
aria-label="edit"
variant="outline"
size="sm"
pl="6px"
pr="6px"
onClick={ handleClick }
icon={ <Icon as={ isAdded ? starFilledIcon : starOutlineIcon } boxSize={ 5 }/> }
onFocusCapture={ usePreventFocusAfterModalClosing() }
/>
</Tooltip>
<WatchlistAddModal
{ ...addModalProps }
isAdd
onClose={ handleAddModalClose }
onSuccess={ handleAddOrDeleteSuccess }
data={ formData }
/>
<DeleteAddressModal
{ ...deleteModalProps }
onClose={ handleDeleteModalClose }
data={ formData }
onSuccess={ handleAddOrDeleteSuccess }
/>
</>
);
};
export default chakra(AddressFavoriteButton);
import { Link } from '@chakra-ui/react';
import React from 'react';
import type { Address } from 'types/api/address';
import link from 'lib/link/link';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
interface Props {
data: Address;
}
const AddressNameInfo = ({ data }: Props) => {
if (data.token) {
return (
<DetailsInfoItem
title="Token name"
hint="Token name and symbol"
>
<Link href={ link('token_index', { hash: data.token.address }) }>
{ data.token.name } ({ data.token.symbol })
</Link>
</DetailsInfoItem>
);
}
if (data.is_contract && data.name) {
return (
<DetailsInfoItem
title="Contract name"
hint="The name found in the source code of the Contract"
>
{ data.name }
</DetailsInfoItem>
);
}
if (data.name) {
return (
<DetailsInfoItem
title="Validator name"
hint="The name of the validator"
>
{ data.name }
</DetailsInfoItem>
);
}
return null;
};
export default React.memo(AddressNameInfo);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import AddressQrCode from './AddressQrCode';
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await mount(
<TestApp>
<AddressQrCode hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29"/>
</TestApp>,
);
await page.getByRole('button', { name: /qr code/i }).click();
await expect(page).toHaveScreenshot();
});
import { chakra, Alert, Icon, Modal, ModalBody, ModalContent, ModalCloseButton, ModalOverlay, Box, useDisclosure, Tooltip, IconButton } from '@chakra-ui/react';
import * as Sentry from '@sentry/react';
import QRCode from 'qrcode';
import React from 'react';
import qrCodeIcon from 'icons/qr_code.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
const SVG_OPTIONS = {
margin: 0,
};
interface Props {
className?: string;
hash: string;
}
const AddressQrCode = ({ hash, className }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const isMobile = useIsMobile();
const [ qr, setQr ] = React.useState('');
const [ error, setError ] = React.useState('');
React.useEffect(() => {
if (isOpen) {
QRCode.toString(hash, SVG_OPTIONS, (error: Error | null | undefined, svg: string) => {
if (error) {
setError('We were unable to generate QR code.');
Sentry.captureException(error, { tags: { source: 'QR code' } });
return;
}
setError('');
setQr(svg);
});
}
}, [ hash, isOpen, onClose ]);
return (
<>
<Tooltip label="Click to view QR code">
<IconButton
className={ className }
aria-label="Show QR code"
variant="outline"
size="sm"
pl="6px"
pr="6px"
onClick={ onOpen }
icon={ <Icon as={ qrCodeIcon } boxSize={ 5 }/> }
/>
</Tooltip>
<Modal isOpen={ isOpen } onClose={ onClose } size={{ base: 'full', lg: 'sm' }}>
<ModalOverlay/>
<ModalContent bgColor={ error ? undefined : 'white' }>
{ isMobile && <ModalCloseButton/> }
<ModalBody mb={ 0 }>
{ error ? <Alert status="warning">{ error }</Alert> : <Box dangerouslySetInnerHTML={{ __html: qr }}/> }
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
export default React.memo(chakra(AddressQrCode));
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import { QueryKeys } from 'types/client/queries';
import useFetch from 'lib/hooks/useFetch';
const MockAddressPage = ({ children }: { children: JSX.Element }): JSX.Element => {
const router = useRouter();
const fetch = useFetch();
const { data } = useQuery(
[ QueryKeys.address, router.query.id ],
async() => await fetch(`/node-api/addresses/${ router.query.id }`),
{
enabled: Boolean(router.query.id),
},
);
if (!data) {
return <div/>;
}
return children;
};
export default MockAddressPage;
import { Flex } from '@chakra-ui/react';
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as tokenBalanceMock from 'mocks/address/tokenBalance';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import MockAddressPage from 'ui/address/testUtils/MockAddressPage';
import TokenSelect from './TokenSelect';
const ASSET_URL = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/poa/assets/0xb2a90505dc6680a7a695f7975d0d32EeF610f456/logo.png';
const TOKENS_API_URL = '/node-api/addresses/1/token-balances';
const ADDRESS_API_URL = '/node-api/addresses/1';
const hooksConfig = {
router: {
query: { id: '1' },
},
};
const CLIPPING_AREA = { x: 0, y: 0, width: 360, height: 500 };
const test = base.extend({
page: async({ page }, use) => {
await page.route(ASSET_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ hash: '1' }),
}), { times: 1 });
await page.route(TOKENS_API_URL, async(route) => route.fulfill({
status: 200,
body: JSON.stringify(tokenBalanceMock.baseList),
}), { times: 1 });
use(page);
},
});
test('base view +@dark-mode', async({ mount, page }) => {
await mount(
<TestApp>
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await page.getByRole('button', { name: /select/i }).click();
await page.getByText('USD Coin').hover();
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
await page.mouse.move(100, 200);
await page.mouse.wheel(0, 1000);
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page }) => {
await mount(
<TestApp>
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await page.getByRole('button', { name: /select/i }).click();
await page.getByText('USD Coin').hover();
await expect(page).toHaveScreenshot();
});
});
test('sort', async({ mount, page }) => {
await mount(
<TestApp>
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await page.getByRole('button', { name: /select/i }).click();
await page.locator('a[aria-label="Sort ERC-20 tokens"]').click();
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
await page.mouse.move(100, 200);
await page.mouse.wheel(0, 1000);
await page.locator('a[aria-label="Sort ERC-1155 tokens"]').click();
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
});
test('filter', async({ mount, page }) => {
await mount(
<TestApp>
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await page.getByRole('button', { name: /select/i }).click();
await page.getByPlaceholder('Search by token name').type('c');
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
});
test.describe('socket', () => {
const testWithSocket = test.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
testWithSocket.describe.configure({ mode: 'serial' });
testWithSocket('new item after token balance update', async({ page, mount, createSocket }) => {
await mount(
<TestApp withSocket>
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await page.route(TOKENS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
...tokenBalanceMock.baseList,
tokenBalanceMock.erc20d,
]),
}));
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:1');
socketServer.sendMessage(socket, channel, 'token_balance', {
block_number: 1,
});
const button = page.getByRole('button', { name: /select/i });
const text = await button.innerText();
expect(text).toContain('10');
});
testWithSocket('new item after coin balance update', async({ page, mount, createSocket }) => {
await mount(
<TestApp withSocket>
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await page.route(TOKENS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
...tokenBalanceMock.baseList,
tokenBalanceMock.erc20d,
]),
}));
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:1');
socketServer.sendMessage(socket, channel, 'coin_balance', {
coin_balance: {
block_number: 1,
},
});
const button = page.getByRole('button', { name: /select/i });
const text = await button.innerText();
expect(text).toContain('10');
});
});
import { Box, Flex, Icon, IconButton, Skeleton, Tooltip } from '@chakra-ui/react';
import { useQuery, useQueryClient, useIsFetching } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { Address, AddressTokenBalance } from 'types/api/address';
import { QueryKeys } from 'types/client/queries';
import walletIcon from 'icons/wallet.svg';
import useFetch from 'lib/hooks/useFetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import TokenSelectDesktop from './TokenSelectDesktop';
import TokenSelectMobile from './TokenSelectMobile';
const TokenSelect = () => {
const router = useRouter();
const fetch = useFetch();
const isMobile = useIsMobile();
const queryClient = useQueryClient();
const [ blockNumber, setBlockNumber ] = React.useState<number>();
const addressQueryData = queryClient.getQueryData<Address>([ QueryKeys.address, router.query.id ]);
const { data, isError, isLoading, refetch } = useQuery<unknown, unknown, Array<AddressTokenBalance>>(
[ QueryKeys.addressTokenBalances, addressQueryData?.hash ],
async() => await fetch(`/node-api/addresses/${ addressQueryData?.hash }/token-balances`),
{
enabled: Boolean(addressQueryData),
},
);
const balancesIsFetching = useIsFetching({ queryKey: [ QueryKeys.addressTokenBalances, addressQueryData?.hash ] });
const handleTokenBalanceMessage: SocketMessage.AddressTokenBalance['handler'] = React.useCallback((payload) => {
if (payload.block_number !== blockNumber) {
refetch();
setBlockNumber(payload.block_number);
}
}, [ blockNumber, refetch ]);
const handleCoinBalanceMessage: SocketMessage.AddressCoinBalance['handler'] = React.useCallback((payload) => {
if (payload.coin_balance.block_number !== blockNumber) {
refetch();
setBlockNumber(payload.coin_balance.block_number);
}
}, [ blockNumber, refetch ]);
const channel = useSocketChannel({
topic: `addresses:${ addressQueryData?.hash.toLowerCase() }`,
isDisabled: !addressQueryData,
});
useSocketMessage({
channel,
event: 'coin_balance',
handler: handleCoinBalanceMessage,
});
useSocketMessage({
channel,
event: 'token_balance',
handler: handleTokenBalanceMessage,
});
if (isLoading) {
return <Skeleton h={ 8 } w="160px"/>;
}
if (isError || data.length === 0) {
return <Box py="6px">0</Box>;
}
return (
<Flex columnGap={ 3 } mt={{ base: '6px', lg: 0 }}>
{ isMobile ?
<TokenSelectMobile data={ data } isLoading={ balancesIsFetching === 1 }/> :
<TokenSelectDesktop data={ data } isLoading={ balancesIsFetching === 1 }/>
}
<Tooltip label="Show all tokens">
<IconButton
aria-label="Show all tokens"
variant="outline"
size="sm"
pl="6px"
pr="6px"
icon={ <Icon as={ walletIcon } boxSize={ 5 }/> }
/>
</Tooltip>
</Flex>
);
};
export default React.memo(TokenSelect);
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';
interface Props {
isOpen: boolean;
isLoading: boolean;
onClick: () => void;
data: Array<EnhancedData>;
}
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 skeletonBgColor = useColorModeValue('white', 'black');
const handleClick = React.useCallback(() => {
if (isLoading && !isOpen) {
return;
}
onClick();
}, [ isLoading, isOpen, onClick ]);
return (
<Box position="relative">
<Button
ref={ ref }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ handleClick }
aria-label="Token select"
>
<Icon as={ tokensIcon } boxSize={ 4 } mr={ 2 }/>
<Text fontWeight={ 600 }>{ data.length }</Text>
<Text whiteSpace="pre" variant="secondary" fontWeight={ 400 }> (${ totalBn.toFormat(2) })</Text>
<Icon as={ arrowIcon } transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 3 }/>
</Button>
{ isLoading && !isOpen && <Skeleton h="100%" w="100%" position="absolute" top={ 0 } left={ 0 } bgColor={ skeletonBgColor }/> }
</Box>
);
};
export default React.forwardRef(TokenSelectButton);
import { useColorModeValue, Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import TokenSelectButton from './TokenSelectButton';
import TokenSelectMenu from './TokenSelectMenu';
import useTokenSelect from './useTokenSelect';
interface Props {
data: Array<AddressTokenBalance>;
isLoading: boolean;
}
const TokenSelectDesktop = ({ data, isLoading }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const bgColor = useColorModeValue('white', 'gray.900');
const result = useTokenSelect(data);
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<TokenSelectButton isOpen={ isOpen } onClick={ onToggle } data={ result.modifiedData } isLoading={ isLoading }/>
</PopoverTrigger>
<PopoverContent w="355px" maxH="450px" overflowY="scroll">
<PopoverBody px={ 4 } py={ 6 } bgColor={ bgColor } boxShadow="2xl" >
<TokenSelectMenu { ...result }/>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(TokenSelectDesktop);
import { Flex, Text, useColorModeValue } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import link from 'lib/link/link';
import HashStringShorten from 'ui/shared/HashStringShorten';
import TokenLogo from 'ui/shared/TokenLogo';
import type { EnhancedData } from './utils';
interface Props {
data: EnhancedData;
}
const TokenSelectItem = ({ data }: Props) => {
const secondRow = (() => {
switch (data.token.type) {
case 'ERC-20': {
const tokenDecimals = Number(data.token.decimals) || 18;
return (
<>
<Text >{ BigNumber(data.value).dividedBy(10 ** tokenDecimals).toFormat(2) } { data.token.symbol }</Text>
{ data.token.exchange_rate && <Text >@{ data.token.exchange_rate }</Text> }
</>
);
}
case 'ERC-721': {
return <Text >{ BigNumber(data.value).toFormat() } { data.token.symbol }</Text>;
}
case 'ERC-1155': {
return (
<>
<Text >#{ data.token_id || 0 }</Text>
<Text >{ BigNumber(data.value).toFormat() }</Text>
</>
);
}
}
})();
// TODO add filter param when token page is ready
const url = link('token_index', { hash: data.token.address });
return (
<Flex
px={ 1 }
py="10px"
display="flex"
flexDir="column"
rowGap={ 2 }
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderBottomWidth="1px"
_hover={{
bgColor: useColorModeValue('blue.50', 'gray.800'),
}}
fontSize="sm"
cursor="pointer"
as="a"
href={ url }
>
<Flex alignItems="center" w="100%">
<TokenLogo hash={ data.token.address } name={ data.token.name } boxSize={ 6 }/>
<Text fontWeight={ 700 } ml={ 2 }>{ data.token.name || <HashStringShorten hash={ data.token.address }/> }</Text>
{ data.usd && <Text fontWeight={ 700 } ml="auto">${ data.usd.toFormat(2) }</Text> }
</Flex>
<Flex alignItems="center" justifyContent="space-between" w="100%">
{ secondRow }
</Flex>
</Flex>
);
};
export default React.memo(TokenSelectItem);
import { Icon, Text, Box, Input, InputGroup, InputLeftElement, useColorModeValue, Flex, Link } from '@chakra-ui/react';
import type { Dictionary } from 'lodash';
import type { ChangeEvent } from 'react';
import React from 'react';
import type { TokenType } from 'types/api/tokenInfo';
import arrowIcon from 'icons/arrows/east.svg';
import searchIcon from 'icons/search.svg';
import TokenSelectItem from './TokenSelectItem';
import type { Sort, EnhancedData } from './utils';
import { sortTokenGroups, sortingFns } from './utils';
interface Props {
searchTerm: string;
erc20sort: Sort;
erc1155sort: Sort;
modifiedData: Array<EnhancedData>;
groupedData: Dictionary<Array<EnhancedData>>;
onInputChange: (event: ChangeEvent<HTMLInputElement>) => void;
onSortClick: (event: React.SyntheticEvent) => void;
}
const TokenSelectMenu = ({ erc20sort, erc1155sort, modifiedData, groupedData, onInputChange, onSortClick, searchTerm }: Props) => {
const searchIconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
const inputBorderColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
return (
<>
<InputGroup size="xs" mb={ 5 }>
<InputLeftElement >
<Icon as={ searchIcon } boxSize={ 4 } color={ searchIconColor }/>
</InputLeftElement>
<Input
paddingInlineStart="38px"
placeholder="Search by token name"
ml="1px"
onChange={ onInputChange }
borderColor={ inputBorderColor }
/>
</InputGroup>
<Flex flexDir="column" rowGap={ 6 }>
{ Object.entries(groupedData).sort(sortTokenGroups).map(([ tokenType, tokenInfo ]) => {
const type = tokenType as TokenType;
const arrowTransform = (type === 'ERC-1155' && erc1155sort === 'desc') || (type === 'ERC-20' && erc20sort === 'desc') ?
'rotate(90deg)' :
'rotate(-90deg)';
const sortDirection: Sort = (() => {
switch (type) {
case 'ERC-1155':
return erc1155sort;
case 'ERC-20':
return erc20sort;
default:
return 'desc';
}
})();
const hasSort = type === 'ERC-1155' || (type === 'ERC-20' && tokenInfo.some(({ usd }) => usd));
return (
<Box key={ type }>
<Flex justifyContent="space-between">
<Text mb={ 3 } color="gray.500" fontWeight={ 600 } fontSize="sm">{ type } tokens ({ tokenInfo.length })</Text>
{ hasSort && (
<Link data-type={ type } onClick={ onSortClick } aria-label={ `Sort ${ type } tokens` }>
<Icon as={ arrowIcon } boxSize={ 5 } transform={ arrowTransform } transitionDuration="faster"/>
</Link>
) }
</Flex>
{ tokenInfo.sort(sortingFns[type](sortDirection)).map((data) => <TokenSelectItem key={ data.token.address + data.token_id } data={ data }/>) }
</Box>
);
}) }
</Flex>
{ modifiedData.length === 0 && searchTerm && <Text fontSize="sm">Could not find any matches.</Text> }
</>
);
};
export default React.memo(TokenSelectMenu);
import { useDisclosure, Modal, ModalContent, ModalCloseButton } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import TokenSelectButton from './TokenSelectButton';
import TokenSelectMenu from './TokenSelectMenu';
import useTokenSelect from './useTokenSelect';
interface Props {
data: Array<AddressTokenBalance>;
isLoading: boolean;
}
const TokenSelectMobile = ({ data, isLoading }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const result = useTokenSelect(data);
return (
<>
<TokenSelectButton isOpen={ isOpen } onClick={ onToggle } data={ result.modifiedData } isLoading={ isLoading }/>
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent>
<ModalCloseButton/>
<TokenSelectMenu { ...result }/>
</ModalContent>
</Modal>
</>
);
};
export default React.memo(TokenSelectMobile);
import _groupBy from 'lodash/groupBy';
import type { ChangeEvent } from 'react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import type { Sort } from './utils';
import { calculateUsdValue, filterTokens } from './utils';
export default function useData(data: Array<AddressTokenBalance>) {
const [ searchTerm, setSearchTerm ] = React.useState('');
const [ erc1155sort, setErc1155Sort ] = React.useState<Sort>('desc');
const [ erc20sort, setErc20Sort ] = React.useState<Sort>('desc');
const onInputChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value);
}, []);
const onSortClick = React.useCallback((event: React.SyntheticEvent) => {
const tokenType = (event.currentTarget as HTMLAnchorElement).getAttribute('data-type');
if (tokenType === 'ERC-1155') {
setErc1155Sort((prevValue) => prevValue === 'desc' ? 'asc' : 'desc');
}
if (tokenType === 'ERC-20') {
setErc20Sort((prevValue) => prevValue === 'desc' ? 'asc' : 'desc');
}
}, []);
const modifiedData = React.useMemo(() => {
return data.filter(filterTokens(searchTerm.toLowerCase())).map(calculateUsdValue);
}, [ data, searchTerm ]);
const groupedData = React.useMemo(() => {
return _groupBy(modifiedData, 'token.type');
}, [ modifiedData ]);
return {
searchTerm,
erc20sort,
erc1155sort,
onInputChange,
onSortClick,
modifiedData,
groupedData,
};
}
import BigNumber from 'bignumber.js';
import type { AddressTokenBalance } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo';
export type EnhancedData = AddressTokenBalance & {
usd?: BigNumber ;
}
export type Sort = 'desc' | 'asc';
const TOKEN_GROUPS_ORDER: Array<TokenType> = [ 'ERC-20', 'ERC-721', 'ERC-1155' ];
type TokenGroup = [string, Array<AddressTokenBalance>];
export const sortTokenGroups = (groupA: TokenGroup, groupB: TokenGroup) => {
return TOKEN_GROUPS_ORDER.indexOf(groupA[0] as TokenType) > TOKEN_GROUPS_ORDER.indexOf(groupB[0] as TokenType) ? 1 : -1;
};
const sortErc1155Tokens = (sort: Sort) => (dataA: AddressTokenBalance, dataB: AddressTokenBalance) => {
if (dataA.value === dataB.value) {
return 0;
}
if (sort === 'desc') {
return Number(dataA.value) > Number(dataB.value) ? -1 : 1;
}
return Number(dataA.value) > Number(dataB.value) ? 1 : -1;
};
const sortErc20Tokens = (sort: Sort) => (dataA: EnhancedData, dataB: EnhancedData) => {
if (!dataA.usd && !dataB.usd) {
return 0;
}
// keep tokens without usd value in the end of the group
if (!dataB.usd) {
return -1;
}
if (!dataA.usd) {
return 0;
}
if (sort === 'desc') {
return dataA.usd.gt(dataB.usd) ? -1 : 1;
}
return dataA.usd.gt(dataB.usd) ? 1 : -1;
};
const sortErc721Tokens = () => () => 0;
export const sortingFns = {
'ERC-20': sortErc20Tokens,
'ERC-721': sortErc721Tokens,
'ERC-1155': sortErc1155Tokens,
};
export const filterTokens = (searchTerm: string) => ({ token }: AddressTokenBalance) => {
if (!token.name) {
return !searchTerm ? true : token.address.toLowerCase().includes(searchTerm);
}
return token.name?.toLowerCase().includes(searchTerm);
};
export const calculateUsdValue = (data: AddressTokenBalance): EnhancedData => {
if (data.token.type !== 'ERC-20') {
return data;
}
const exchangeRate = data.token.exchange_rate;
if (!exchangeRate) {
return data;
}
const decimals = Number(data.token.decimals || '18');
return {
...data,
usd: BigNumber(data.value).div(BigNumber(10 ** decimals)).multipliedBy(BigNumber(exchangeRate)),
};
};
......@@ -112,9 +112,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
isInvalid={ Boolean(errors.name) }
maxLength={ NAME_MAX_LENGTH }
/>
<FormLabel>
<InputPlaceholder text="Application name for API key (e.g Web3 project)" error={ errors.name }/>
</FormLabel>
<InputPlaceholder text="Application name for API key (e.g Web3 project)" error={ errors.name }/>
</FormControl>
);
}, [ errors, formBackgroundColor ]);
......
import { Grid, GridItem, Skeleton, SkeletonCircle } from '@chakra-ui/react';
import { Grid, GridItem, Skeleton } from '@chakra-ui/react';
import React from 'react';
const SkeletonRow = ({ w = '100%' }: { w?: string }) => (
<>
<GridItem display="flex" columnGap={ 2 } w={{ base: '50%', lg: 'auto' }} _notFirst={{ mt: { base: 3, lg: 0 } }}>
<SkeletonCircle h={ 5 } w={ 5 }/>
<Skeleton flexGrow={ 1 } h={ 5 } borderRadius="full"/>
</GridItem>
<GridItem pl={{ base: 7, lg: 0 }}>
<Skeleton h={ 5 } borderRadius="full" w={{ base: '100%', lg: w }}/>
</GridItem>
</>
);
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
const BlockDetailsSkeleton = () => {
const sectionGap = <GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>;
return (
<Grid columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '210px 1fr' }} maxW="1000px">
<SkeletonRow w="25%"/>
<SkeletonRow w="25%"/>
<SkeletonRow w="65%"/>
<SkeletonRow w="25%"/>
<SkeletonRow/>
<SkeletonRow/>
<DetailsSkeletonRow w="25%"/>
<DetailsSkeletonRow w="25%"/>
<DetailsSkeletonRow w="65%"/>
<DetailsSkeletonRow w="25%"/>
<DetailsSkeletonRow/>
<DetailsSkeletonRow/>
{ sectionGap }
<SkeletonRow w="50%"/>
<SkeletonRow w="25%"/>
<SkeletonRow w="50%"/>
<SkeletonRow w="50%"/>
<SkeletonRow w="50%"/>
<DetailsSkeletonRow w="50%"/>
<DetailsSkeletonRow w="25%"/>
<DetailsSkeletonRow w="50%"/>
<DetailsSkeletonRow w="50%"/>
<DetailsSkeletonRow w="50%"/>
{ sectionGap }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Skeleton h={ 5 } borderRadius="full" w="100px"/>
......
......@@ -82,10 +82,10 @@ const BlocksContent = ({ type, query }: Props) => {
if (query.isLoading) {
return (
<>
<Show below="lg" key="skeleton-mobile">
<Show below="lg" key="skeleton-mobile" ssr={ false }>
<BlocksSkeletonMobile/>
</Show>
<Hide below="lg" key="skeleton-desktop">
<Hide below="lg" key="skeleton-desktop" ssr={ false }>
<SkeletonTable columns={ [ '125px', '120px', '21%', '64px', '35%', '22%', '22%' ] }/>
</Hide>
</>
......@@ -103,8 +103,8 @@ const BlocksContent = ({ type, query }: Props) => {
return (
<>
{ socketAlert && <Alert status="warning" mb={ 6 } as="a" href={ window.document.location.href }>{ socketAlert }</Alert> }
<Show below="lg" key="content-mobile"><BlocksList data={ query.data.items }/></Show>
<Hide below="lg" key="content-desktop"><BlocksTable data={ query.data.items } top={ 80 } page={ 1 }/></Hide>
<Show below="lg" key="content-mobile" ssr={ false }><BlocksList data={ query.data.items }/></Show>
<Hide below="lg" key="content-desktop" ssr={ false }><BlocksTable data={ query.data.items } top={ 80 } page={ 1 }/></Hide>
</>
);
......@@ -115,7 +115,7 @@ const BlocksContent = ({ type, query }: Props) => {
return (
<>
{ isMobile && !isPaginatorHidden && (
<ActionBar>
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...query.pagination }/>
</ActionBar>
) }
......
......@@ -21,7 +21,7 @@ const BlocksTabSlot = ({ pagination }: Props) => {
const statsQuery = useQuery<unknown, unknown, HomeStats>(
[ QueryKeys.homeStats ],
() => fetch('/node-api/stats'),
() => fetch('/node-api/home-stats'),
);
if (isMobile) {
......
......@@ -8,7 +8,7 @@ import TestApp from 'playwright/TestApp';
import LatestBlocks from './LatestBlocks';
const STATS_API_URL = '/node-api/stats';
const STATS_API_URL = '/node-api/home-stats';
const BLOCKS_API_URL = '/node-api/index/blocks';
export const test = base.extend<socketServer.SocketServerFixture>({
......
......@@ -33,7 +33,7 @@ const LatestBlocks = () => {
const queryClient = useQueryClient();
const statsQueryResult = useQuery<unknown, unknown, HomeStats>(
[ QueryKeys.homeStats ],
() => fetch('/node-api/stats'),
() => fetch('/node-api/home-stats'),
);
const handleNewBlockMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => {
......
......@@ -14,7 +14,7 @@ export const test = base.extend<socketServer.SocketServerFixture>({
});
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route('/node-api/stats', (route) => route.fulfill({
await page.route('/node-api/home-stats', (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
......@@ -47,7 +47,7 @@ test.describe('socket', () => {
};
test('new item', async({ mount, page, createSocket }) => {
await page.route('/node-api/stats', (route) => route.fulfill({
await page.route('/node-api/home-stats', (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
......
......@@ -7,7 +7,7 @@ import TestApp from 'playwright/TestApp';
import Stats from './Stats';
const API_URL = '/node-api/stats';
const API_URL = '/node-api/home-stats';
test('all items +@mobile +@dark-mode +@desktop-xl', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
......
......@@ -29,7 +29,7 @@ const Stats = () => {
const { data, isLoading, isError } = useQuery<unknown, unknown, HomeStats>(
[ QueryKeys.homeStats ],
async() => await fetch(`/node-api/stats`),
async() => await fetch(`/node-api/home-stats`),
);
if (isError) {
......
......@@ -7,8 +7,8 @@ import TestApp from 'playwright/TestApp';
import ChainIndicators from './ChainIndicators';
const STATS_API_URL = '/node-api/stats';
const TX_CHART_API_URL = '/node-api/stats/charts/transactions';
const STATS_API_URL = '/node-api/home-stats';
const TX_CHART_API_URL = '/node-api/home-stats/charts/transactions';
test('daily txs chart +@mobile +@dark-mode +@dark-mode-mobile', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
......
......@@ -37,7 +37,7 @@ const ChainIndicators = () => {
const fetch = useFetch();
const statsQueryResult = useQuery<unknown, unknown, HomeStats>(
[ QueryKeys.homeStats ],
() => fetch('/node-api/stats'),
() => fetch('/node-api/home-stats'),
);
const bgColorDesktop = useColorModeValue('white', 'gray.900');
......
......@@ -19,7 +19,7 @@ const dailyTxsIndicator: TChainIndicator<QueryKeys.chartsTxs> = {
hint: `The total daily number of transactions on the blockchain for the last month.`,
api: {
queryName: QueryKeys.chartsTxs,
path: '/node-api/stats/charts/transactions',
path: '/node-api/home-stats/charts/transactions',
dataFn: (response) => ([ {
items: response.chart_data
.map((item) => ({ date: new Date(item.date), value: item.tx_count }))
......@@ -38,7 +38,7 @@ const coinPriceIndicator: TChainIndicator<QueryKeys.chartsMarket> = {
hint: `${ appConfig.network.currency.symbol } token daily price in USD.`,
api: {
queryName: QueryKeys.chartsMarket,
path: '/node-api/stats/charts/market',
path: '/node-api/home-stats/charts/market',
dataFn: (response) => ([ {
items: response.chart_data
.map((item) => ({ date: new Date(item.date), value: Number(item.closing_price) }))
......@@ -58,7 +58,7 @@ const marketPriceIndicator: TChainIndicator<QueryKeys.chartsMarket> = {
hint: 'The total market value of a cryptocurrency\'s circulating supply. It is analogous to the free-float capitalization in the stock market. Market Cap = Current Price x Circulating Supply.',
api: {
queryName: QueryKeys.chartsMarket,
path: '/node-api/stats/charts/market',
path: '/node-api/home-stats/charts/market',
dataFn: (response) => ([ {
items: response.chart_data
.map((item) => ({ date: new Date(item.date), value: Number(item.closing_price) * Number(response.available_supply) }))
......
......@@ -9,6 +9,7 @@ import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import useFetch from 'lib/hooks/useFetch';
import AddressDetails from 'ui/address/AddressDetails';
import AddressTxs from 'ui/address/AddressTxs';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
......@@ -32,7 +33,7 @@ const AddressPageContent = () => {
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const tabs: Array<RoutedTab> = [
{ id: 'txs', title: 'Transactions', component: null },
{ id: 'txs', title: 'Transactions', component: <AddressTxs/> },
{ id: 'token_transfers', title: 'Token transfers', component: null },
{ id: 'tokens', title: 'Tokens', component: null },
{ id: 'internal_txn', title: 'Internal txn', component: null },
......
......@@ -9,7 +9,7 @@ import TestApp from 'playwright/TestApp';
import Blocks from './Blocks';
const BLOCKS_API_URL = '/node-api/blocks?type=block';
const STATS_API_URL = '/node-api/stats';
const STATS_API_URL = '/node-api/home-stats';
const hooksConfig = {
router: {
query: { tab: 'blocks' },
......@@ -17,7 +17,7 @@ const hooksConfig = {
},
};
export const test = base.extend<socketServer.SocketServerFixture>({
const test = base.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
......
......@@ -11,7 +11,7 @@ import TestApp from 'playwright/TestApp';
import Home from './Home';
test('default view -@default +@desktop-xl +@mobile +@dark-mode', async({ mount, page }) => {
await page.route('/node-api/stats', (route) => route.fulfill({
await page.route('/node-api/home-stats', (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
......@@ -30,7 +30,7 @@ test('default view -@default +@desktop-xl +@mobile +@dark-mode', async({ mount,
txMock.withTokenTransfer,
]),
}));
await page.route('/node-api/stats/charts/transactions', (route) => route.fulfill({
await page.route('/node-api/home-stats/charts/transactions', (route) => route.fulfill({
status: 200,
body: JSON.stringify(dailyTxsMock.base),
}));
......
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.
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