Commit 26e3e22b authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into home-fixes

parents a0940fab 1193c911
......@@ -4,6 +4,7 @@ NEXT_PUBLIC_APP_INSTANCE=__PLACEHOLDER_FOR_NEXT_PUBLIC_APP_INSTANCE__
NEXT_PUBLIC_APP_PROTOCOL=__PLACEHOLDER_FOR_NEXT_PUBLIC_APP_PROTOCOL__
NEXT_PUBLIC_APP_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_APP_HOST__
NEXT_PUBLIC_APP_PORT=__PLACEHOLDER_FOR_NEXT_PUBLIC_APP_PORT__
NEXT_PUBLIC_AUTH_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_AUTH_URL__
# network config
NEXT_PUBLIC_NETWORK_NAME=__PLACEHOLDER_FOR_NEXT_PUBLIC_NETWORK_NAME__
......
......@@ -68,6 +68,7 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM | `string` | Link to form where authors can submit their dapps to the marketplace | `https://airtable.com/shrqUAcjgGJ4jU88C` |
| NEXT_PUBLIC_NETWORK_EXPLORERS | `Array<NetworkExplorer>` where `NetworkExplorer` can have following [properties](#network-explorer-configuration-properties) | Used to build up links to transactions, blocks, addresses in other chain explorers. | `[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/tx'}}]` |
| NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE | `validation` or `mining` *(optional)* | Verification type in the network | `mining` |
| NEXT_PUBLIC_AUTH_URL | `string` *(optional)* | Account auth base url; the login path (`/auth/auth0`) will be added to it at execution time | `https://blockscout.com` |
| NEXT_PUBLIC_LOGOUT_URL | `string` *(optional)* | Account logout url | `https://blockscoutcom.us.auth0.com/v2/logout` |
| NEXT_PUBLIC_LOGOUT_RETURN_URL | `string` *(optional)* | Account logout return url | `https://blockscout.com/poa/core/auth/logout` |
| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'coin_price' \| 'market_cup'>` *(optional)* | List of charts displayed on the home page | `['daily_txs','coin_price','market_cup']` |
......
......@@ -25,6 +25,7 @@ const baseUrl = [
process.env.NEXT_PUBLIC_VERCEL_URL || appHost,
appPort && ':' + appPort,
].filter(Boolean).join('');
const authUrl = getEnvValue(process.env.NEXT_PUBLIC_AUTH_URL) || baseUrl;
const apiHost = getEnvValue(process.env.NEXT_PUBLIC_API_HOST);
const logoutUrl = (() => {
......@@ -81,6 +82,7 @@ const config = Object.freeze({
host: appHost,
port: appPort,
baseUrl,
authUrl,
logoutUrl,
api: {
endpoint: apiHost ? `https://${ apiHost }` : 'https://blockscout.com',
......
# ui config
NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network
NEXT_PUBLIC_FOOTER_STAKING_LINK=https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_FEATURED_NETWORKS=[{'title':'Gnosis Chain','url':'https://blockscout.com/xdai/mainnet','group':'mainnets','type':'xdai_mainnet'},{'title':'Optimism on Gnosis Chain','url':'https://blockscout.com/xdai/optimism','group':'mainnets','icon':'https://www.fillmurray.com/60/60','type':'xdai_optimism'},{'title':'Arbitrum on xDai','url':'https://blockscout.com/xdai/aox','group':'mainnets'},{'title':'Ethereum','url':'https://blockscout.com/eth/mainnet','group':'mainnets','type':'eth_mainnet'},{'title':'Ethereum Classic','url':'https://blockscout.com/etx/mainnet','group':'mainnets','type':'etc_mainnet'},{'title':'POA','url':'https://blockscout.com/poa/core','group':'mainnets','type':'poa_core'},{'title':'RSK','url':'https://blockscout.com/rsk/mainnet','group':'mainnets','type':'rsk_mainnet'},{'title':'Gnosis Chain Testnet','url':'https://blockscout.com/xdai/testnet','group':'testnets','type':'xdai_testnet'},{'title':'POA Sokol','url':'https://blockscout.com/poa/sokol','group':'testnets','type':'poa_sokol'},{'title':'ARTIS Σ1','url':'https://blockscout.com/artis/sigma1','group':'other','type':'artis_sigma1'},{'title':'LUKSO L14','url':'https://blockscout.com/lukso/l14','group':'other','type':'lukso_l14'},{'title':'Astar','url':'https://blockscout.com/astar','group':'other','type':'astar'}]
NEXT_PUBLIC_FEATURED_NETWORKS=[{'title':'Gnosis Chain','url':'https://blockscout.com/xdai/mainnet','group':'mainnets','type':'xdai_mainnet'},{'title':'Optimism on Gnosis Chain','url':'https://blockscout.com/xdai/optimism','group':'mainnets','type':'xdai_optimism'},{'title':'Arbitrum on xDai','url':'https://blockscout.com/xdai/aox','group':'mainnets'},{'title':'Ethereum','url':'https://blockscout.com/eth/mainnet','group':'mainnets','type':'eth_mainnet'},{'title':'Ethereum Classic','url':'https://blockscout.com/etx/mainnet','group':'mainnets','type':'etc_mainnet'},{'title':'POA','url':'https://blockscout.com/poa/core','group':'mainnets','type':'poa_core'},{'title':'RSK','url':'https://blockscout.com/rsk/mainnet','group':'mainnets','type':'rsk_mainnet'},{'title':'Gnosis Chain Testnet','url':'https://blockscout.com/xdai/testnet','group':'testnets','type':'xdai_testnet'},{'title':'POA Sokol','url':'https://blockscout.com/poa/sokol','group':'testnets','type':'poa_sokol'},{'title':'ARTIS Σ1','url':'https://blockscout.com/artis/sigma1','group':'other','type':'artis_sigma1'},{'title':'LUKSO L14','url':'https://blockscout.com/lukso/l14','group':'other','type':'lukso_l14'},{'title':'Astar','url':'https://blockscout.com/astar','group':'other','type':'astar'}]
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%)
......
......@@ -472,7 +472,7 @@ frontend:
environment:
# ui config
NEXT_PUBLIC_FEATURED_NETWORKS:
_default: "[{'title':'Gnosis Chain','url':'https://blockscout.com/xdai/mainnet','group':'mainnets','type':'xdai_mainnet'},{'title':'Optimism on Gnosis Chain','url':'https://blockscout.com/xdai/optimism','group':'mainnets','icon':'https://www.fillmurray.com/60/60','type':'xdai_optimism'},{'title':'Arbitrum on xDai','url':'https://blockscout.com/xdai/aox','group':'mainnets'},{'title':'Ethereum','url':'https://blockscout.com/eth/mainnet','group':'mainnets','type':'eth_mainnet'},{'title':'Ethereum Classic','url':'https://blockscout.com/etx/mainnet','group':'mainnets','type':'etc_mainnet'},{'title':'POA','url':'https://blockscout.com/poa/core','group':'mainnets','type':'poa_core'},{'title':'RSK','url':'https://blockscout.com/rsk/mainnet','group':'mainnets','type':'rsk_mainnet'},{'title':'Gnosis Chain Testnet','url':'https://blockscout.com/xdai/testnet','group':'testnets','type':'xdai_testnet'},{'title':'POA Sokol','url':'https://blockscout.com/poa/sokol','group':'testnets','type':'poa_sokol'},{'title':'ARTIS Σ1','url':'https://blockscout.com/artis/sigma1','group':'other','type':'artis_sigma1'},{'title':'LUKSO L14','url':'https://blockscout.com/lukso/l14','group':'other','type':'lukso_l14'},{'title':'Astar','url':'https://blockscout.com/astar','group':'other','type':'astar'}]"
_default: "[{'title':'Gnosis Chain','url':'https://blockscout.com/xdai/mainnet','group':'mainnets','type':'xdai_mainnet'},{'title':'Optimism on Gnosis Chain','url':'https://blockscout.com/xdai/optimism','group':'mainnets','type':'xdai_optimism'},{'title':'Arbitrum on xDai','url':'https://blockscout.com/xdai/aox','group':'mainnets'},{'title':'Ethereum','url':'https://blockscout.com/eth/mainnet','group':'mainnets','type':'eth_mainnet'},{'title':'Ethereum Classic','url':'https://blockscout.com/etx/mainnet','group':'mainnets','type':'etc_mainnet'},{'title':'POA','url':'https://blockscout.com/poa/core','group':'mainnets','type':'poa_core'},{'title':'RSK','url':'https://blockscout.com/rsk/mainnet','group':'mainnets','type':'rsk_mainnet'},{'title':'Gnosis Chain Testnet','url':'https://blockscout.com/xdai/testnet','group':'testnets','type':'xdai_testnet'},{'title':'POA Sokol','url':'https://blockscout.com/poa/sokol','group':'testnets','type':'poa_sokol'},{'title':'ARTIS Σ1','url':'https://blockscout.com/artis/sigma1','group':'other','type':'artis_sigma1'},{'title':'LUKSO L14','url':'https://blockscout.com/lukso/l14','group':'other','type':'lukso_l14'},{'title':'Astar','url':'https://blockscout.com/astar','group':'other','type':'astar'}]"
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: "[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]"
# network config
......
......@@ -367,10 +367,12 @@ frontend:
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED:
_default: 'true'
NEXT_PUBLIC_FEATURED_NETWORKS:
_default: "[{'title':'Gnosis Chain','url':'https://blockscout.com/xdai/mainnet','group':'mainnets','type':'xdai_mainnet'},{'title':'Optimism on Gnosis Chain','url':'https://blockscout.com/xdai/optimism','group':'mainnets','icon':'https://www.fillmurray.com/60/60','type':'xdai_optimism'},{'title':'Arbitrum on xDai','url':'https://blockscout.com/xdai/aox','group':'mainnets'},{'title':'Ethereum','url':'https://blockscout.com/eth/mainnet','group':'mainnets','type':'eth_mainnet'},{'title':'Ethereum Classic','url':'https://blockscout.com/etx/mainnet','group':'mainnets','type':'etc_mainnet'},{'title':'POA','url':'https://blockscout.com/poa/core','group':'mainnets','type':'poa_core'},{'title':'RSK','url':'https://blockscout.com/rsk/mainnet','group':'mainnets','type':'rsk_mainnet'},{'title':'Gnosis Chain Testnet','url':'https://blockscout.com/xdai/testnet','group':'testnets','type':'xdai_testnet'},{'title':'POA Sokol','url':'https://blockscout.com/poa/sokol','group':'testnets','type':'poa_sokol'},{'title':'ARTIS Σ1','url':'https://blockscout.com/artis/sigma1','group':'other','type':'artis_sigma1'},{'title':'LUKSO L14','url':'https://blockscout.com/lukso/l14','group':'other','type':'lukso_l14'},{'title':'Astar','url':'https://blockscout.com/astar','group':'other','type':'astar'}]"
_default: "[{'title':'Gnosis Chain','url':'https://blockscout.com/xdai/mainnet','group':'mainnets','type':'xdai_mainnet'},{'title':'Optimism on Gnosis Chain','url':'https://blockscout.com/xdai/optimism','group':'mainnets','type':'xdai_optimism'},{'title':'Arbitrum on xDai','url':'https://blockscout.com/xdai/aox','group':'mainnets'},{'title':'Ethereum','url':'https://blockscout.com/eth/mainnet','group':'mainnets','type':'eth_mainnet'},{'title':'Ethereum Classic','url':'https://blockscout.com/etx/mainnet','group':'mainnets','type':'etc_mainnet'},{'title':'POA','url':'https://blockscout.com/poa/core','group':'mainnets','type':'poa_core'},{'title':'RSK','url':'https://blockscout.com/rsk/mainnet','group':'mainnets','type':'rsk_mainnet'},{'title':'Gnosis Chain Testnet','url':'https://blockscout.com/xdai/testnet','group':'testnets','type':'xdai_testnet'},{'title':'POA Sokol','url':'https://blockscout.com/poa/sokol','group':'testnets','type':'poa_sokol'},{'title':'ARTIS Σ1','url':'https://blockscout.com/artis/sigma1','group':'other','type':'artis_sigma1'},{'title':'LUKSO L14','url':'https://blockscout.com/lukso/l14','group':'other','type':'lukso_l14'},{'title':'Astar','url':'https://blockscout.com/astar','group':'other','type':'astar'}]"
NEXT_PUBLIC_API_HOST:
_default: blockscout.com
review: blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_AUTH_URL:
_default: https://blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_API_BASE_PATH:
_default: /
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM:
......
......@@ -2,22 +2,27 @@ import clamp from 'lodash/clamp';
import throttle from 'lodash/throttle';
import React from 'react';
const ScrollDirectionContext = React.createContext<'up' | 'down' | null>(null);
import isBrowser from 'lib/isBrowser';
const SCROLL_DIFF_THRESHOLD = 20;
type Directions = 'up' | 'down';
export default function useScrollDirection() {
interface Props {
children: React.ReactNode;
}
export function ScrollDirectionProvider({ children }: Props) {
const prevScrollPosition = React.useRef(isBrowser() ? window.pageYOffset : 0);
const [ scrollDirection, setDirection ] = React.useState<Directions>();
const [ scrollDirection, setDirection ] = React.useState<Directions | null>(null);
const handleScroll = React.useCallback(() => {
const currentScrollPosition = clamp(window.pageYOffset, 0, window.document.body.scrollHeight - window.innerHeight);
const scrollDiff = currentScrollPosition - prevScrollPosition.current;
if (window.pageYOffset === 0) {
setDirection(undefined);
setDirection(null);
} else if (Math.abs(scrollDiff) > SCROLL_DIFF_THRESHOLD) {
setDirection(scrollDiff < 0 ? 'up' : 'down');
}
......@@ -37,5 +42,17 @@ export default function useScrollDirection() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return scrollDirection;
return (
<ScrollDirectionContext.Provider value={ scrollDirection }>
{ children }
</ScrollDirectionContext.Provider>
);
}
export function useScrollDirection() {
const context = React.useContext(ScrollDirectionContext);
if (context === undefined) {
throw new Error('useScrollDirection must be used within a ScrollDirectionProvider');
}
return context;
}
import _clamp from 'lodash/clamp';
import React from 'react';
const MAX_DELAY = 500;
const MIN_DELAY = 50;
const DURATION = 300;
export default function useGradualIncrement(initialValue: number): [number, (inc: number) => void] {
const [ num, setNum ] = React.useState(initialValue);
const queue = React.useRef<number>(0);
const timeoutId = React.useRef(0);
const delay = React.useRef(0);
const incrementDelayed = React.useCallback(() => {
if (queue.current === 0) {
......@@ -33,8 +32,13 @@ export default function useGradualIncrement(initialValue: number): [number, (inc
React.useEffect(() => {
if (queue.current > 0 && !timeoutId.current) {
const delay = _clamp(MAX_DELAY / queue.current * 1.5, MIN_DELAY, MAX_DELAY);
timeoutId.current = window.setTimeout(incrementDelayed, delay);
if (!delay.current) {
delay.current = DURATION / queue.current;
} else if (delay.current > DURATION / queue.current) {
// in case if queue size is increased since last DOM update
delay.current = DURATION / queue.current;
}
timeoutId.current = window.setTimeout(incrementDelayed, delay.current);
}
}, [ incrementDelayed, num ]);
......
import throttle from 'lodash/throttle';
import React from 'react';
export default function useIsSticky(ref: React.RefObject<HTMLDivElement>, offset = 0, isEnabled = true) {
const [ isSticky, setIsSticky ] = React.useState(false);
const handleScroll = React.useCallback(() => {
if (
Number(ref.current?.getBoundingClientRect().y) < offset
) {
setIsSticky(true);
} else {
setIsSticky(false);
}
}, [ ref, offset ]);
React.useEffect(() => {
if (!isEnabled) {
return;
}
const throttledHandleScroll = throttle(handleScroll, 300);
window.addEventListener('scroll', throttledHandleScroll);
return () => {
window.removeEventListener('scroll', throttledHandleScroll);
};
// replicate componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isEnabled ]);
return isSticky;
}
......@@ -52,11 +52,11 @@ export default function useNewTxsSocket() {
}, [ setNum ]);
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please click here to load new transactions.');
setSocketAlert('Connection is lost. Please reload page.');
}, []);
const handleSocketError = React.useCallback(() => {
setSocketAlert('An error has occurred while fetching new transactions. Please click here to refresh the page.');
setSocketAlert('An error has occurred while fetching new transactions. Please reload page.');
}, []);
const channel = useSocketChannel({
......
......@@ -22,13 +22,17 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
const paginationFields = PAGINATION_FIELDS[queryName];
const queryClient = useQueryClient();
const router = useRouter();
const [ page, setPage ] = React.useState(1);
const [ page, setPage ] = React.useState<number>(router.query.page && !Array.isArray(router.query.page) ? Number(router.query.page) : 1);
const currPageParams = pick(router.query, paginationFields);
const [ pageParams, setPageParams ] = React.useState<Array<PaginationParams<QueryName>>>([ ]);
const fetch = useFetch();
const isMounted = React.useRef(false);
const canGoBackwards = React.useRef(!router.query.page);
const queryKey = [ queryName, ...(queryIds || []), { page, filters } ];
const queryResult = useQuery<unknown, unknown, PaginatedResponse<QueryName>>(
[ queryName, ...(queryIds || []), { page, filters } ],
queryKey,
async() => {
const params: Array<string> = [];
......@@ -42,7 +46,7 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
return fetch(`${ apiPath }${ params.length ? '?' + params.join('&') : '' }`);
},
{ staleTime: Infinity, ...options },
{ staleTime: page === 1 ? 0 : Infinity, ...options },
);
const { data } = queryResult;
......@@ -57,6 +61,8 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
}
const nextPageQuery = { ...router.query };
Object.entries(nextPageParams).forEach(([ key, val ]) => nextPageQuery[key] = val.toString());
nextPageQuery.page = String(page + 1);
router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true })
.then(() => {
animateScroll.scrollToTop({ duration: 0 });
......@@ -67,13 +73,14 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
const onPrevPageClick = useCallback(() => {
// returning to the first page
// we dont have pagination params for the first page
let nextPageQuery: typeof router.query;
let nextPageQuery: typeof router.query = {};
if (page === 2) {
nextPageQuery = omit(router.query, paginationFields);
nextPageQuery = omit(router.query, paginationFields, 'page');
canGoBackwards.current = true;
} else {
const nextPageParams = { ...pageParams[page - 2] };
nextPageQuery = { ...router.query };
nextPageParams && Object.entries(nextPageParams).forEach(([ key, val ]) => nextPageQuery[key] = val.toString());
nextPageQuery.page = String(page - 1);
}
router.query = nextPageQuery;
router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true })
......@@ -86,10 +93,11 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
const resetPage = useCallback(() => {
queryClient.clear();
router.push({ pathname: router.pathname, query: omit(router.query, paginationFields) }, undefined, { shallow: true }).then(() => {
router.push({ pathname: router.pathname, query: omit(router.query, paginationFields, 'page') }, undefined, { shallow: true }).then(() => {
animateScroll.scrollToTop({ duration: 0 });
setPage(1);
setPageParams([ ]);
canGoBackwards.current = true;
});
}, [ queryClient, router, paginationFields ]);
......@@ -103,7 +111,23 @@ export default function useQueryWithPages<QueryName extends PaginatedQueryKeys>(
hasPaginationParams,
resetPage,
hasNextPage: nextPageParams ? Object.keys(nextPageParams).length > 0 : false,
canGoBackwards: canGoBackwards.current,
};
React.useEffect(() => {
if (page !== 1 && isMounted.current) {
queryClient.cancelQueries({ queryKey });
setPage(1);
}
// hook should run only when queryName has changed
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ queryName ]);
React.useEffect(() => {
window.setTimeout(() => {
isMounted.current = true;
}, 0);
}, []);
return { ...queryResult, pagination };
}
......@@ -25,8 +25,9 @@ export default function link(
return paramValue ? `/${ paramValue }` : '';
});
const baseUrl = routeName === 'auth' ? appConfig.authUrl : appConfig.baseUrl;
const url = new URL(path, appConfig.baseUrl);
const url = new URL(path, baseUrl);
queryParams && Object.entries(queryParams).forEach(([ key, value ]) => {
url.searchParams.append(key, value);
......
......@@ -7,19 +7,16 @@ import React, { useState } from 'react';
import appConfig from 'configs/app/config';
import { AppContextProvider } from 'lib/appContext';
import { Chakra } from 'lib/Chakra';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
import useConfigSentry from 'lib/hooks/useConfigSentry';
import type { ErrorType } from 'lib/hooks/useFetch';
import useScrollDirection from 'lib/hooks/useScrollDirection';
import { SocketProvider } from 'lib/socket/context';
import theme from 'theme';
import ScrollDirectionContext from 'ui/ScrollDirectionContext';
import AppError from 'ui/shared/AppError/AppError';
import ErrorBoundary from 'ui/shared/ErrorBoundary';
function MyApp({ Component, pageProps }: AppProps) {
useConfigSentry();
const directionContext = useScrollDirection();
const [ queryClient ] = useState(() => new QueryClient({
defaultOptions: {
queries: {
......@@ -62,11 +59,11 @@ function MyApp({ Component, pageProps }: AppProps) {
<ErrorBoundary renderErrorScreen={ renderErrorScreen } onError={ handleError }>
<AppContextProvider pageProps={ pageProps }>
<QueryClientProvider client={ queryClient }>
<ScrollDirectionContext.Provider value={ directionContext }>
<ScrollDirectionProvider>
<SocketProvider url={ `${ appConfig.api.socket }${ appConfig.api.basePath }/socket/v2` }>
<Component { ...pageProps }/>
</SocketProvider>
</ScrollDirectionContext.Provider>
</ScrollDirectionProvider>
<ReactQueryDevtools/>
</QueryClientProvider>
</AppContextProvider>
......
......@@ -56,6 +56,9 @@ const variantOutline = defineStyle((props) => {
if (c === 'gray-dark') {
return mode('blue.700', 'gray.50')(props);
}
if (c === 'blue') {
return mode('blue.400', 'gray.50')(props);
}
return 'blue.400';
})();
......
......@@ -44,6 +44,7 @@ export interface BlockTransactionsResponse {
next_page_params: {
block_number: number;
items_count: number;
index: number;
} | null;
}
......
......@@ -9,6 +9,8 @@ import type { KeysOfObjectOrNull } from 'types/utils/KeysOfObjectOrNull';
export type PaginatedQueryKeys =
QueryKeys.blocks |
QueryKeys.blocksReorgs |
QueryKeys.blocksUncles |
QueryKeys.blockTxs |
QueryKeys.txsValidate |
QueryKeys.txsPending |
......@@ -17,7 +19,7 @@ export type PaginatedQueryKeys =
QueryKeys.txTokenTransfers;
export type PaginatedResponse<Q extends PaginatedQueryKeys> =
Q extends QueryKeys.blocks ? BlocksResponse :
Q extends (QueryKeys.blocks | QueryKeys.blocksReorgs | QueryKeys.blocksUncles) ? BlocksResponse :
Q extends QueryKeys.blockTxs ? BlockTransactionsResponse :
Q extends QueryKeys.txsValidate ? TransactionsResponseValidated :
Q extends QueryKeys.txsPending ? TransactionsResponsePending :
......@@ -41,7 +43,9 @@ type PaginationFields = {
export const PAGINATION_FIELDS: PaginationFields = {
[QueryKeys.blocks]: [ 'block_number', 'items_count' ],
[QueryKeys.blockTxs]: [ 'block_number', 'items_count' ],
[QueryKeys.blocksReorgs]: [ 'block_number', 'items_count' ],
[QueryKeys.blocksUncles]: [ 'block_number', 'items_count' ],
[QueryKeys.blockTxs]: [ 'block_number', 'items_count', 'index' ],
[QueryKeys.txsValidate]: [ 'block_number', 'items_count', 'filter', 'index' ],
[QueryKeys.txsPending]: [ 'filter', 'hash', 'inserted_at' ],
[QueryKeys.txInternals]: [ 'block_number', 'items_count', 'transaction_hash', 'index', 'transaction_index' ],
......
import type { AddressParam } from './addressParams';
import type { BlockTransactionsResponse } from './block';
import type { DecodedInput } from './decodedInput';
import type { Fee } from './fee';
import type { TokenTransfer } from './tokenTransfer';
......@@ -71,3 +72,5 @@ export interface TransactionsResponsePending {
}
export type TransactionType = 'token_transfer' | 'contract_creation' | 'contract_call' | 'token_creation' | 'coin_transfer'
export type TxsResponse = TransactionsResponseValidated | TransactionsResponsePending | BlockTransactionsResponse;
......@@ -13,6 +13,8 @@ export enum QueryKeys {
blockTxs = 'block-transactions',
block = 'block',
blocks = 'blocks',
blocksReorgs = 'blocks-reorgs',
blocksUncles = 'blocks-uncles',
chartsTxs = 'charts-txs',
chartsMarket = 'charts-market',
indexBlocks='indexBlocks',
......
import React from 'react';
const ScrollDirectionContext = React.createContext<'up' | 'down' | undefined>(undefined);
export default ScrollDirectionContext;
import { useRouter } from 'next/router';
import React from 'react';
import { QueryKeys } from 'types/client/queries';
import TxsContent from 'ui/txs/TxsContent';
const BlockTxs = () => {
const router = useRouter();
return (
<TxsContent
queryName={ QueryKeys.blockTxs }
apiPath={ `/node-api/blocks/${ router.query.id }/transactions` }
showBlockInfo={ false }
/>
);
};
export default BlockTxs;
import { Text, Show, Hide, Skeleton, Alert } from '@chakra-ui/react';
import { Text, Show, Hide, Alert } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
......@@ -6,7 +7,7 @@ import type { SocketMessage } from 'lib/socket/types';
import type { BlockType, BlocksResponse } from 'types/api/block';
import { QueryKeys } from 'types/client/queries';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import useIsMobile from 'lib/hooks/useIsMobile';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import BlocksList from 'ui/blocks/BlocksList';
......@@ -15,25 +16,36 @@ import BlocksTable from 'ui/blocks/BlocksTable';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SkeletonTable from 'ui/shared/SkeletonTable';
type QueryResult = UseQueryResult<BlocksResponse> & {
pagination: PaginationProps;
};
interface Props {
type?: BlockType;
query: QueryResult;
}
const BlocksContent = ({ type }: Props) => {
const BlocksContent = ({ type, query }: Props) => {
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const [ socketAlert, setSocketAlert ] = React.useState('');
const { data, isLoading, isError, pagination } = useQueryWithPages({
apiPath: '/node-api/blocks',
queryName: QueryKeys.blocks,
filters: { type },
});
const isPaginatorHidden = !isLoading && !isError && pagination.page === 1 && !pagination.hasNextPage;
const handleNewBlockMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => {
queryClient.setQueryData([ QueryKeys.blocks, { page: pagination.page, filters: { type } } ], (prevData: BlocksResponse | undefined) => {
const queryKey = (() => {
switch (type) {
case 'uncle':
return QueryKeys.blocksUncles;
case 'reorg':
return QueryKeys.blocksReorgs;
default:
return QueryKeys.blocks;
}
})();
queryClient.setQueryData([ queryKey, { page: query.pagination.page, filters: { type } } ], (prevData: BlocksResponse | undefined) => {
const shouldAddToList = !type || type === payload.block.type;
if (!prevData) {
......@@ -44,7 +56,7 @@ const BlocksContent = ({ type }: Props) => {
}
return shouldAddToList ? { ...prevData, items: [ payload.block, ...prevData.items ] } : prevData;
});
}, [ pagination.page, queryClient, type ]);
}, [ query.pagination.page, queryClient, type ]);
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please click here to load new blocks.');
......@@ -58,7 +70,7 @@ const BlocksContent = ({ type }: Props) => {
topic: 'blocks:new_block',
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: isLoading || isError || pagination.page !== 1,
isDisabled: query.isLoading || query.isError || query.pagination.page !== 1,
});
useSocketMessage({
channel,
......@@ -67,7 +79,7 @@ const BlocksContent = ({ type }: Props) => {
});
const content = (() => {
if (isLoading) {
if (query.isLoading) {
return (
<>
<Show below="lg" key="skeleton-mobile">
......@@ -80,37 +92,31 @@ const BlocksContent = ({ type }: Props) => {
);
}
if (isError) {
if (query.isError) {
return <DataFetchAlert/>;
}
if (data.items.length === 0) {
if (query.data.items.length === 0) {
return <Text as="span">There are no blocks.</Text>;
}
return (
<>
{ socketAlert && <Alert status="warning" mb={ 6 } as="a" href={ window.document.location.href }>{ socketAlert }</Alert> }
<Show below="lg" key="content-mobile"><BlocksList data={ data.items }/></Show>
<Hide below="lg" key="content-desktop"><BlocksTable data={ data.items } top={ isPaginatorHidden ? 0 : 80 } page={ pagination.page }/></Hide>
<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>
</>
);
})();
const totalText = data?.items.length ?
<Text mb={{ base: 0, lg: 6 }}>Total of { data.items[0].height.toLocaleString() } blocks</Text> :
null;
const isPaginatorHidden = !query.isLoading && !query.isError && query.pagination.page === 1 && !query.pagination.hasNextPage;
return (
<>
{ !isLoading ?
totalText :
<Skeleton h="24px" w="200px" mb={{ base: 0, lg: 6 }}/>
}
{ !isPaginatorHidden && (
{ isMobile && !isPaginatorHidden && (
<ActionBar>
<Pagination ml="auto" { ...pagination }/>
<Pagination ml="auto" { ...query.pagination }/>
</ActionBar>
) }
{ content }
......@@ -118,4 +124,4 @@ const BlocksContent = ({ type }: Props) => {
);
};
export default BlocksContent;
export default React.memo(BlocksContent);
import { Flex, Box, Text, Skeleton } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { HomeStats } from 'types/api/stats';
import { QueryKeys } from 'types/client/queries';
import useFetch from 'lib/hooks/useFetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
interface Props {
pagination: PaginationProps;
}
const BlocksTabSlot = ({ pagination }: Props) => {
const isMobile = useIsMobile();
const fetch = useFetch();
const statsQuery = useQuery<unknown, unknown, HomeStats>(
[ QueryKeys.homeStats ],
() => fetch('/node-api/stats'),
);
if (isMobile) {
return null;
}
return (
<Flex alignItems="center" columnGap={ 8 }>
{ statsQuery.isLoading && <Skeleton w="175px" h="24px"/> }
{ statsQuery.data?.network_utilization_percentage !== undefined && (
<Box>
<Text as="span" fontSize="sm">
Network utilization (last 50 blocks):{ nbsp }
</Text>
<Text as="span" fontSize="sm" color="blue.400" fontWeight={ 600 }>
{ statsQuery.data.network_utilization_percentage.toFixed(2) }%
</Text>
</Box>
) }
<Pagination my={ 1 } { ...pagination }/>
</Flex>
);
};
export default BlocksTabSlot;
......@@ -8,10 +8,10 @@ import { QueryKeys } from 'types/client/queries';
import useFetch from 'lib/hooks/useFetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import link from 'lib/link/link';
import TxsNewItemNotice from 'ui/txs/TxsNewItemNotice';
import LatestTxsItem from './LatestTxsItem';
import LatestTxsItemSkeleton from './LatestTxsItemSkeleton';
import LatestTxsNotice from './LatestTxsNotice';
const LatestTransactions = () => {
const isMobile = useIsMobile();
......@@ -41,7 +41,7 @@ const LatestTransactions = () => {
const txsUrl = link('txs');
content = (
<>
<LatestTxsNotice/>
<TxsNewItemNotice borderBottomRadius={ 0 } url={ link('txs') }/>
<Box mb={{ base: 3, lg: 4 }}>
{ data.slice(0, txsCount).map((tx => <LatestTxsItem key={ tx.hash } tx={ tx }/>)) }
</Box>
......
import { Alert, Text, Link, useColorModeValue, useTheme } from '@chakra-ui/react';
import { transparentize } from '@chakra-ui/theme-tools';
import React from 'react';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
import link from 'lib/link/link';
interface Props {
className?: string;
}
const LatestTxsNotice = ({ className }: Props) => {
const { num, socketAlert } = useNewTxsSocket();
let content;
if (socketAlert) {
content = 'Connection is lost. Please reload page';
} else if (!num) {
content = (
<Text>scanning new transactions...</Text>
);
} else {
const txsUrl = link('txs');
content = (
<>
<Link href={ txsUrl }>{ num } more transaction{ num > 1 ? 's' : '' }</Link>
<Text whiteSpace="pre"> ha{ num > 1 ? 've' : 's' } come in</Text>
</>
);
}
const theme = useTheme();
return (
<Alert
className={ className }
status="warning"
h="32px"
px={ 4 }
py="6px"
fontWeight={ 400 }
bgColor={ useColorModeValue('orange.50', transparentize('orange.200', 0.16)(theme)) }
color={ useColorModeValue('blackAlpha.800', 'whiteAlpha.800') }
borderBottomRadius={ 0 }
>
{ content }
</Alert>
);
};
export default LatestTxsNotice;
import { useRouter } from 'next/router';
import React from 'react';
import { QueryKeys } from 'types/client/queries';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import BlockDetails from 'ui/block/BlockDetails';
import BlockTxs from 'ui/block/BlockTxs';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/Pagination';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TxsContent from 'ui/txs/TxsContent';
const TABS: Array<RoutedTab> = [
{ id: 'index', title: 'Details', component: <BlockDetails/> },
{ id: 'txs', title: 'Transactions', component: <BlockTxs/> },
];
const TAB_LIST_PROPS = {
marginBottom: 0,
py: 5,
marginTop: -5,
};
const BlockPageContent = () => {
const router = useRouter();
const isMobile = useIsMobile();
const blockTxsQuery = useQueryWithPages({
apiPath: `/node-api/blocks/${ router.query.id }/transactions`,
queryName: QueryKeys.blockTxs,
options: {
enabled: Boolean(router.query.id && router.query.tab === 'txs'),
},
});
if (!router.query.id) {
return null;
}
const tabs: Array<RoutedTab> = [
{ id: 'index', title: 'Details', component: <BlockDetails/> },
{ id: 'txs', title: 'Transactions', component: <TxsContent query={ blockTxsQuery } showBlockInfo={ false } showSocketInfo={ false }/> },
];
return (
<Page>
<PageTitle text={ `Block #${ router.query.id }` }/>
<RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 8 }}/>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
rightSlot={ isMobile ? null : <Pagination { ...blockTxsQuery.pagination }/> }
stickyEnabled={ !isMobile }
/>
</Page>
);
};
......
......@@ -2,12 +2,20 @@ import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as blockMock from 'mocks/blocks/block';
import * as statsMock from 'mocks/stats/index';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import BlocksContent from './BlocksContent';
import Blocks from './Blocks';
const API_URL = '/node-api/blocks';
const BLOCKS_API_URL = '/node-api/blocks?type=block';
const STATS_API_URL = '/node-api/stats';
const hooksConfig = {
router: {
query: { tab: 'blocks' },
isReady: true,
},
};
export const test = base.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
......@@ -17,32 +25,38 @@ export const test = base.extend<socketServer.SocketServerFixture>({
// test cases which use socket cannot run in parallel since the socket server always run on the same port
test.describe.configure({ mode: 'serial' });
test('base view +@mobile', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blockMock.baseListResponse),
}));
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
const component = await mount(
<TestApp>
<BlocksContent/>
<Blocks/>
</TestApp>,
{ hooksConfig },
);
await page.waitForResponse(API_URL);
await page.waitForResponse(BLOCKS_API_URL);
await expect(component).toHaveScreenshot();
await expect(component.locator('main')).toHaveScreenshot();
});
test('new item from socket', async({ mount, page, createSocket }) => {
await page.route(API_URL, (route) => route.fulfill({
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blockMock.baseListResponse),
}));
const component = await mount(
<TestApp withSocket>
<BlocksContent/>
<Blocks/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
......@@ -56,24 +70,25 @@ test('new item from socket', async({ mount, page, createSocket }) => {
},
});
await expect(component).toHaveScreenshot();
await expect(component.locator('main')).toHaveScreenshot();
});
test('socket error', async({ mount, page, createSocket }) => {
await page.route(API_URL, (route) => route.fulfill({
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blockMock.baseListResponse),
}));
const component = await mount(
<TestApp withSocket>
<BlocksContent/>
<Blocks/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
await socketServer.joinChannel(socket, 'blocks:new_block');
socket.close();
await expect(component).toHaveScreenshot();
await expect(component.locator('main')).toHaveScreenshot();
});
import { useRouter } from 'next/router';
import React from 'react';
import type { BlockType } from 'types/api/block';
import { QueryKeys } from 'types/client/queries';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import BlocksContent from 'ui/blocks/BlocksContent';
import BlocksTabSlot from 'ui/blocks/BlocksTabSlot';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
const TABS: Array<RoutedTab> = [
{ id: 'blocks', title: 'All', component: <BlocksContent/> },
{ id: 'reorgs', title: 'Forked', component: <BlocksContent type="reorg"/> },
{ id: 'uncles', title: 'Uncles', component: <BlocksContent type="uncle"/> },
];
const TAB_TO_TYPE: Record<string, BlockType> = {
blocks: 'block',
reorgs: 'reorg',
uncles: 'uncle',
};
const TAB_TO_QUERY: Record<string, QueryKeys.blocks | QueryKeys.blocksReorgs | QueryKeys.blocksUncles> = {
blocks: QueryKeys.blocks,
reorgs: QueryKeys.blocksReorgs,
uncles: QueryKeys.blocksUncles,
};
const TAB_LIST_PROPS = {
marginBottom: 0,
py: 5,
marginTop: -5,
};
const BlocksPageContent = () => {
const router = useRouter();
const isMobile = useIsMobile();
const type = router.query.tab && !Array.isArray(router.query.tab) ? TAB_TO_TYPE[router.query.tab] : 'block';
const queryName = router.query.tab && !Array.isArray(router.query.tab) ? TAB_TO_QUERY[router.query.tab] : QueryKeys.blocks;
const blocksQuery = useQueryWithPages({
apiPath: '/node-api/blocks',
queryName,
filters: { type },
});
const tabs: Array<RoutedTab> = [
{ id: 'blocks', title: 'All', component: <BlocksContent type="block" query={ blocksQuery }/> },
{ id: 'reorgs', title: 'Forked', component: <BlocksContent type="reorg" query={ blocksQuery }/> },
{ id: 'uncles', title: 'Uncles', component: <BlocksContent type="uncle" query={ blocksQuery }/> },
];
return (
<Page>
<PageTitle text="Blocks"/>
<RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 8 }}/>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
rightSlot={ <BlocksTabSlot pagination={ blocksQuery.pagination }/> }
stickyEnabled={ !isMobile }
/>
</Page>
);
};
......
......@@ -20,7 +20,7 @@ const PrivateTags = () => {
return (
<Page>
<PageTitle text="Private tags"/>
<RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 8 }}/>
<RoutedTabs tabs={ TABS }/>
</Page>
);
};
......
......@@ -82,7 +82,7 @@ const TransactionPageContent = () => {
</Flex>
) }
</Flex>
<RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 8 }}/>
<RoutedTabs tabs={ TABS }/>
</Page>
);
};
......
import {
Box,
} from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { QueryKeys } from 'types/client/queries';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import appConfig from 'configs/app/config';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TxsTab from 'ui/txs/TxsTab';
import TxsContent from 'ui/txs/TxsContent';
import TxsTabSlot from 'ui/txs/TxsTabSlot';
const TAB_LIST_PROPS = {
marginBottom: 0,
py: 5,
marginTop: -5,
};
const Transactions = () => {
const verifiedTitle = appConfig.network.verificationType === 'validation' ? 'Validated' : 'Mined';
const TABS: Array<RoutedTab> = [
{ id: 'validated', title: verifiedTitle, component: <TxsTab tab="validated"/> },
{ id: 'pending', title: 'Pending', component: <TxsTab tab="pending"/> },
const router = useRouter();
const isMobile = useIsMobile();
const filter = router.query.tab === 'pending' ? 'pending' : 'validated';
const txsQuery = useQueryWithPages({
apiPath: '/node-api/transactions',
queryName: filter === 'validated' ? QueryKeys.txsValidate : QueryKeys.txsPending,
filters: { filter },
});
const tabs: Array<RoutedTab> = [
{ id: 'validated', title: verifiedTitle, component: <TxsContent query={ txsQuery }/> },
{ id: 'pending', title: 'Pending', component: <TxsContent query={ txsQuery } showBlockInfo={ false }/> },
];
return (
<Page hideMobileHeaderOnScrollDown>
<Box h="100%">
<PageTitle text="Transactions"/>
<RoutedTabs tabs={ TABS } tabListMarginBottom={{ base: 6, lg: 8 }}/>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
rightSlot={ <TxsTabSlot pagination={ txsQuery.pagination }/> }
stickyEnabled={ !isMobile }
/>
</Box>
</Page>
);
......
import { VStack, Textarea, Button, Alert, AlertTitle, AlertDescription, Link, Code } from '@chakra-ui/react';
import { VStack, Textarea, Button, Alert, AlertTitle, AlertDescription, Link, Code, Flex, Box } from '@chakra-ui/react';
import * as Sentry from '@sentry/react';
import type { ChangeEvent } from 'react';
import React from 'react';
import appConfig from 'configs/app/config';
import * as cookies from 'lib/cookies';
import useGradualIncrement from 'lib/hooks/useGradualIncrement';
import useToast from 'lib/hooks/useToast';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -12,6 +13,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
{ /* will be deleted when we move to new CI */ }
const Vercel = () => {
const toast = useToast();
const [ num, setNum ] = useGradualIncrement(0);
const [ isFormVisible, setFormVisibility ] = React.useState(false);
const [ token, setToken ] = React.useState('');
......@@ -47,10 +49,20 @@ const Vercel = () => {
const prodUrl = 'https://blockscout.com/poa/core';
const handleNumIncrement = React.useCallback(() => {
for (let index = 0; index < 5; index++) {
setNum(5);
}
}, [ setNum ]);
return (
<Page>
<VStack gap={ 4 } alignItems="flex-start" maxW="1000px">
<PageTitle text="Vercel page"/>
<Flex columnGap={ 2 } alignItems="center">
<Box w="50px" textAlign="center">{ num }</Box>
<Button onClick={ handleNumIncrement } size="sm">add</Button>
</Flex>
{ isFormVisible && (
<>
<Alert status="error" flexDirection="column" alignItems="flex-start">
......
import { Flex, useColorModeValue, chakra } from '@chakra-ui/react';
import throttle from 'lodash/throttle';
import React from 'react';
import ScrollDirectionContext from 'ui/ScrollDirectionContext';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
import useIsSticky from 'lib/hooks/useIsSticky';
type Props = {
children: React.ReactNode;
......@@ -13,36 +13,12 @@ const TOP_UP = 106;
const TOP_DOWN = 0;
const ActionBar = ({ children, className }: Props) => {
const [ isSticky, setIsSticky ] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const handleScroll = React.useCallback(() => {
if (
Number(ref.current?.getBoundingClientRect().y) < TOP_UP + 5
) {
setIsSticky(true);
} else {
setIsSticky(false);
}
}, [ ]);
React.useEffect(() => {
const throttledHandleScroll = throttle(handleScroll, 300);
window.addEventListener('scroll', throttledHandleScroll);
return () => {
window.removeEventListener('scroll', throttledHandleScroll);
};
// replicate componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const scrollDirection = useScrollDirection();
const isSticky = useIsSticky(ref, TOP_UP + 5);
const bgColor = useColorModeValue('white', 'black');
return (
<ScrollDirectionContext.Consumer>
{ (scrollDirection) => (
<Flex
className={ className }
backgroundColor={ bgColor }
......@@ -61,8 +37,6 @@ const ActionBar = ({ children, className }: Props) => {
>
{ children }
</Flex>
) }
</ScrollDirectionContext.Consumer>
);
};
......
......@@ -44,7 +44,7 @@ const Page = ({
return (
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Flex flexDir="column" width="100%">
<Flex flexDir="column" flexGrow={ 1 } w={{ base: '100%', lg: 'auto' }}>
<Header isHomePage={ isHomePage } hideOnScrollDown={ hideMobileHeaderOnScrollDown }/>
<ErrorBoundary renderErrorScreen={ renderErrorScreen }>
{ renderedChildren }
......
......@@ -11,9 +11,10 @@ export type Props = {
hasNextPage: boolean;
hasPaginationParams?: boolean;
className?: string;
canGoBackwards: boolean;
}
const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNextPage, hasPaginationParams, className }: Props) => {
const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNextPage, hasPaginationParams, className, canGoBackwards }: Props) => {
return (
<Flex
......@@ -37,8 +38,8 @@ const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNext
aria-label="Next page"
w="36px"
icon={ <Icon as={ arrowIcon } w={ 5 } h={ 5 }/> }
mr={ 6 }
disabled={ page === 1 }
mr={ 3 }
disabled={ !canGoBackwards || page === 1 }
/>
<Button
variant="outline"
......@@ -59,7 +60,7 @@ const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNext
aria-label="Next page"
w="36px"
icon={ <Icon as={ arrowIcon } w={ 5 } h={ 5 } transform="rotate(180deg)"/> }
ml={ 6 }
ml={ 3 }
disabled={ !hasNextPage }
/>
{ /* not implemented yet */ }
......
......@@ -5,6 +5,8 @@ import {
TabList,
TabPanel,
TabPanels,
Box,
useColorModeValue,
} from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system';
import { useRouter } from 'next/router';
......@@ -12,7 +14,9 @@ import React, { useEffect, useState } from 'react';
import type { RoutedTab } from './types';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsSticky from 'lib/hooks/useIsSticky';
import RoutedTabsMenu from './RoutedTabsMenu';
import useAdaptiveTabs from './useAdaptiveTabs';
......@@ -26,12 +30,16 @@ const hiddenItemStyles: StyleProps = {
interface Props {
tabs: Array<RoutedTab>;
tabListMarginBottom?: ChakraProps['marginBottom'];
tabListProps?: ChakraProps;
rightSlot?: React.ReactNode;
stickyEnabled?: boolean;
}
const RoutedTabs = ({ tabs, tabListMarginBottom }: Props) => {
const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled }: Props) => {
const router = useRouter();
const scrollDirection = useScrollDirection();
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(tabs.length + 1);
useEffect(() => {
if (router.isReady) {
let tabIndex = 0;
......@@ -46,7 +54,9 @@ const RoutedTabs = ({ tabs, tabListMarginBottom }: Props) => {
}, [ tabs, router ]);
const isMobile = useIsMobile();
const { tabsCut, tabsList, tabsRefs, listRef } = useAdaptiveTabs(tabs, isMobile);
const { tabsCut, tabsList, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabs, isMobile);
const isSticky = useIsSticky(listRef, 5, stickyEnabled);
const listBgColor = useColorModeValue('white', 'black');
const handleTabChange = React.useCallback((index: number) => {
const nextTab = tabs[index];
......@@ -59,9 +69,16 @@ const RoutedTabs = ({ tabs, tabListMarginBottom }: Props) => {
}, [ tabs, router ]);
return (
<Tabs variant="soft-rounded" colorScheme="blue" isLazy onChange={ handleTabChange } index={ activeTabIndex }>
<Tabs
variant="soft-rounded"
colorScheme="blue"
isLazy
onChange={ handleTabChange }
index={ activeTabIndex }
position="relative"
>
<TabList
marginBottom={ tabListMarginBottom }
marginBottom={{ base: 6, lg: 8 }}
flexWrap="nowrap"
whiteSpace="nowrap"
ref={ listRef }
......@@ -77,6 +94,18 @@ const RoutedTabs = ({ tabs, tabListMarginBottom }: Props) => {
'-ms-overflow-style': 'none', /* IE and Edge */
'scrollbar-width': 'none', /* Firefox */
}}
bgColor={ listBgColor }
transitionProperty="top,box-shadow,background-color,color"
transitionDuration="slow"
{
...(stickyEnabled ? {
position: 'sticky',
boxShadow: { base: isSticky ? 'md' : 'none', lg: 'none' },
top: { base: scrollDirection === 'down' ? `0px` : `106px`, lg: 0 },
zIndex: { base: 'sticky2', lg: 'docked' },
} : { })
}
{ ...tabListProps }
>
{ tabsList.map((tab, index) => {
if (!tab.id) {
......@@ -111,6 +140,7 @@ const RoutedTabs = ({ tabs, tabListMarginBottom }: Props) => {
</Tab>
);
}) }
{ rightSlot ? <Box ref={ rightSlotRef } ml="auto" > { rightSlot } </Box> : null }
</TabList>
<TabPanels>
{ tabsList.map((tab) => <TabPanel padding={ 0 } key={ tab.id }>{ tab.component }</TabPanel>) }
......
......@@ -11,9 +11,11 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
const [ tabsCut, setTabsCut ] = React.useState(disabled ? tabs.length : 0);
const [ tabsRefs, setTabsRefs ] = React.useState<Array<React.RefObject<HTMLButtonElement>>>([]);
const listRef = React.useRef<HTMLDivElement>(null);
const rightSlotRef = React.useRef<HTMLDivElement>(null);
const calculateCut = React.useCallback(() => {
const listWidth = listRef.current?.getBoundingClientRect().width;
const rightSlotWidth = rightSlotRef.current?.getBoundingClientRect().width || 0;
const tabWidths = tabsRefs.map((tab) => tab.current?.getBoundingClientRect().width);
const menuWidth = tabWidths[tabWidths.length - 1];
......@@ -26,11 +28,11 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
return result;
}
if (result.accWidth + item <= listWidth - menuWidth) {
if (result.accWidth + item <= listWidth - rightSlotWidth - menuWidth) {
return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item };
}
if (result.accWidth + item <= listWidth && index === tabWidths.length - 2) {
if (result.accWidth + item <= listWidth - rightSlotWidth && index === tabWidths.length - 2) {
return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item };
}
......@@ -82,6 +84,7 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
tabsList,
tabsRefs,
listRef,
rightSlotRef,
};
}, [ tabsList, tabsCut, tabsRefs, listRef ]);
}
import { HStack, Box, Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import ScrollDirectionContext from 'ui/ScrollDirectionContext';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import ProfileMenuMobile from 'ui/snippets/profileMenu/ProfileMenuMobile';
......@@ -17,10 +17,9 @@ type Props = {
const Header = ({ hideOnScrollDown, isHomePage }: Props) => {
const bgColor = useColorModeValue('white', 'black');
const scrollDirection = useScrollDirection();
return (
<ScrollDirectionContext.Consumer>
{ (scrollDirection) => (
<>
<Box bgColor={ bgColor } display={{ base: 'block', lg: 'none' }}>
<Flex
......@@ -65,8 +64,6 @@ const Header = ({ hideOnScrollDown, isHomePage }: Props) => {
</HStack>
) }
</>
) }
</ScrollDirectionContext.Consumer>
);
};
......
......@@ -4,7 +4,7 @@ import React from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import searchIcon from 'icons/search.svg';
import ScrollDirectionContext from 'ui/ScrollDirectionContext';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
const TOP = 55;
......@@ -17,6 +17,7 @@ interface Props {
const SearchBarMobile = ({ onChange, onSubmit, withShadow }: Props) => {
const [ isSticky, setIsSticky ] = React.useState(false);
const scrollDirection = useScrollDirection();
const handleScroll = React.useCallback(() => {
if (window.pageYOffset !== 0) {
......@@ -43,8 +44,6 @@ const SearchBarMobile = ({ onChange, onSubmit, withShadow }: Props) => {
const bgColor = useColorModeValue('white', 'black');
return (
<ScrollDirectionContext.Consumer>
{ (scrollDirection) => (
<chakra.form
noValidate
onSubmit={ onSubmit }
......@@ -76,8 +75,6 @@ const SearchBarMobile = ({ onChange, onSubmit, withShadow }: Props) => {
/>
</InputGroup>
</chakra.form>
) }
</ScrollDirectionContext.Consumer>
);
};
......
import { Text, Box, Show, Hide } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { TTxsFilters } from 'types/api/txsFilters';
import type { QueryKeys } from 'types/client/queries';
import type { TxsResponse } from 'types/api/transaction';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import useIsMobile from 'lib/hooks/useIsMobile';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import TxsHeader from './TxsHeader';
import TxsHeaderMobile from './TxsHeaderMobile';
import TxsListItem from './TxsListItem';
import TxsNewItemNotice from './TxsNewItemNotice';
import TxsSkeletonDesktop from './TxsSkeletonDesktop';
......@@ -15,30 +16,20 @@ import TxsSkeletonMobile from './TxsSkeletonMobile';
import TxsTable from './TxsTable';
import useTxsSort from './useTxsSort';
type QueryResult = UseQueryResult<TxsResponse> & {
pagination: PaginationProps;
};
type Props = {
queryName: QueryKeys.txsPending | QueryKeys.txsValidate | QueryKeys.blockTxs;
stateFilter?: TTxsFilters['filter'];
apiPath: string;
query: QueryResult;
showBlockInfo?: boolean;
showSocketInfo?: boolean;
}
const TxsContent = ({
queryName,
stateFilter,
apiPath,
showBlockInfo = true,
}: Props) => {
const {
pagination,
...queryResult
} = useQueryWithPages({
apiPath,
queryName,
filters: stateFilter ? { filter: stateFilter } : undefined,
});
// } = useQueryWithPages({ ...filters, filter: stateFilter, apiPath });
const { data, isLoading, isError, setSortByField, setSortByValue, sorting } = useTxsSort(queryResult);
const isPaginatorHidden = !isLoading && !isError && pagination.page === 1 && !pagination.hasNextPage;
const TxsContent = ({ query, showBlockInfo = true, showSocketInfo = true }: Props) => {
const { data, isLoading, isError, setSortByField, setSortByValue, sorting } = useTxsSort(query);
const isPaginatorHidden = !isLoading && !isError && query.pagination.page === 1 && !query.pagination.hasNextPage;
const isMobile = useIsMobile();
const content = (() => {
if (isError) {
......@@ -64,14 +55,23 @@ const TxsContent = ({
<>
<Show below="lg" ssr={ false }>
<Box>
<TxsNewItemNotice>
{ showSocketInfo && (
<TxsNewItemNotice url={ window.location.href }>
{ ({ content }) => <Box>{ content }</Box> }
</TxsNewItemNotice>
) }
{ txs.map(tx => <TxsListItem tx={ tx } key={ tx.hash } showBlockInfo={ showBlockInfo }/>) }
</Box>
</Show>
<Hide below="lg" ssr={ false }>
<TxsTable txs={ txs } sort={ setSortByField } sorting={ sorting } showBlockInfo={ showBlockInfo } top={ isPaginatorHidden ? 0 : 80 }/>
<TxsTable
txs={ txs }
sort={ setSortByField }
sorting={ sorting }
showBlockInfo={ showBlockInfo }
showSocketInfo={ showSocketInfo }
top={ isPaginatorHidden ? 0 : 80 }
/>
</Hide>
</>
);
......@@ -79,7 +79,15 @@ const TxsContent = ({
return (
<>
<TxsHeader mt={ -6 } sorting={ sorting } setSorting={ setSortByValue } paginationProps={ pagination } showPagination={ !isPaginatorHidden }/>
{ isMobile && (
<TxsHeaderMobile
mt={ -6 }
sorting={ sorting }
setSorting={ setSortByValue }
paginationProps={ query.pagination }
showPagination={ !isPaginatorHidden }
/>
) }
{ content }
</>
);
......
......@@ -3,7 +3,6 @@ import React from 'react';
import type { Sort } from 'types/client/txs-sort';
import useIsMobile from 'lib/hooks/useIsMobile';
// import FilterInput from 'ui/shared/FilterInput';
import ActionBar from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/Pagination';
......@@ -20,13 +19,7 @@ type Props = {
showPagination?: boolean;
}
const TxsHeader = ({ sorting, setSorting, paginationProps, className, showPagination = true }: Props) => {
const isMobile = useIsMobile(false);
if (!showPagination && !isMobile) {
return null;
}
const TxsHeaderMobile = ({ sorting, setSorting, paginationProps, className, showPagination = true }: Props) => {
return (
<ActionBar className={ className }>
<HStack>
......@@ -36,13 +29,11 @@ const TxsHeader = ({ sorting, setSorting, paginationProps, className, showPagina
onFiltersChange={ setFilters }
appliedFiltersNum={ 0 }
/> */ }
{ isMobile && (
<TxsSorting
isActive={ Boolean(sorting) }
setSorting={ setSorting }
sorting={ sorting }
/>
) }
{ /* api is not implemented */ }
{ /* <FilterInput
// eslint-disable-next-line react/jsx-no-bind
......@@ -57,4 +48,4 @@ const TxsHeader = ({ sorting, setSorting, paginationProps, className, showPagina
);
};
export default chakra(TxsHeader);
export default chakra(TxsHeaderMobile);
......@@ -23,7 +23,7 @@ test.describe.configure({ mode: 'serial' });
test('new item in validated txs list', async({ mount, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice/>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
......@@ -35,10 +35,29 @@ test('new item in validated txs list', async({ mount, createSocket }) => {
await expect(component).toHaveScreenshot();
});
test.describe('dark mode', () => {
test.use({ colorScheme: 'dark' });
test('default view', async({ mount, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'transactions:new_transaction');
socketServer.sendMessage(socket, channel, 'transaction', { transaction: 1 });
await expect(component).toHaveScreenshot();
});
});
test('2 new items in validated txs list', async({ mount, page, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice/>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
......@@ -56,7 +75,7 @@ test('2 new items in validated txs list', async({ mount, page, createSocket }) =
test('connection loss', async({ mount, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice/>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
......@@ -71,7 +90,7 @@ test('connection loss', async({ mount, createSocket }) => {
test('fetching', async({ mount, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<TxsNewItemNotice/>
<TxsNewItemNotice url="/"/>
</TestApp>,
{ hooksConfig },
);
......
import { Alert, Link, Text, chakra } from '@chakra-ui/react';
import { Alert, Link, Text, chakra, useTheme, useColorModeValue } from '@chakra-ui/react';
import { transparentize } from '@chakra-ui/theme-tools';
import React from 'react';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
......@@ -10,47 +11,45 @@ interface InjectedProps {
interface Props {
children?: (props: InjectedProps) => JSX.Element;
className?: string;
url: string;
}
const TxsNewItemNotice = ({ children, className }: Props) => {
const TxsNewItemNotice = ({ children, className, url }: Props) => {
const { num, socketAlert } = useNewTxsSocket();
const theme = useTheme();
const handleClick = React.useCallback(() => {
window.location.reload();
}, []);
const content = (() => {
const alertContent = (() => {
if (socketAlert) {
return (
<Alert
className={ className }
status="warning"
p={ 4 }
borderRadius={ 0 }
onClick={ handleClick }
cursor="pointer"
>
{ socketAlert }
</Alert>
);
return socketAlert;
}
if (!num) {
return (
<Alert className={ className } status="warning" p={ 4 } fontWeight={ 400 }>
scanning new transactions...
</Alert>
);
return 'scanning new transactions...';
}
return (
<Alert className={ className } status="warning" p={ 4 } fontWeight={ 400 }>
<Link onClick={ handleClick }>{ num } more transaction{ num > 1 ? 's' : '' }</Link>
<>
<Link href={ url }>{ num } more transaction{ num > 1 ? 's' : '' }</Link>
<Text whiteSpace="pre"> ha{ num > 1 ? 've' : 's' } come in</Text>
</Alert>
</>
);
})();
const content = (
<Alert
className={ className }
status="warning"
px={ 4 }
py="6px"
fontWeight={ 400 }
fontSize="sm"
bgColor={ useColorModeValue('orange.50', transparentize('orange.200', 0.16)(theme)) }
color={ useColorModeValue('blackAlpha.800', 'whiteAlpha.800') }
>
{ alertContent }
</Alert>
);
return children ? children({ content }) : content;
};
......
import React from 'react';
import { QueryKeys } from 'types/client/queries';
import TxsContent from './TxsContent';
type Props = {
tab: 'validated' | 'pending';
}
const TxsTab = ({ tab }: Props) => {
if (tab === 'validated') {
return (
<TxsContent
queryName={ QueryKeys.txsValidate }
stateFilter="validated"
apiPath="/node-api/transactions"
/>
);
}
return (
<TxsContent
queryName={ QueryKeys.txsPending }
stateFilter="pending"
apiPath="/node-api/transactions"
showBlockInfo={ false }
/>
);
};
export default TxsTab;
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
interface Props {
pagination: PaginationProps;
}
const TxsTabSlot = ({ pagination }: Props) => {
const isMobile = useIsMobile();
if (isMobile) {
return null;
}
return <Pagination my={ 1 } { ...pagination }/>;
};
export default TxsTabSlot;
......@@ -17,9 +17,10 @@ type Props = {
sorting?: Sort;
top: number;
showBlockInfo: boolean;
showSocketInfo: boolean;
}
const TxsTable = ({ txs, sort, sorting, top, showBlockInfo }: Props) => {
const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo }: Props) => {
return (
<Table variant="simple" minWidth="950px" size="xs">
<TheadSticky top={ top }>
......@@ -49,9 +50,11 @@ const TxsTable = ({ txs, sort, sorting, top, showBlockInfo }: Props) => {
</Tr>
</TheadSticky>
<Tbody>
<TxsNewItemNotice borderRadius={ 0 }>
{ showSocketInfo && (
<TxsNewItemNotice borderRadius={ 0 } url={ window.location.href }>
{ ({ content }) => <Tr><Td colSpan={ 10 } p={ 0 }>{ content }</Td></Tr> }
</TxsNewItemNotice>
) }
{ txs.map((item) => (
<TxsTableItem
key={ item.hash }
......
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { BlockTransactionsResponse } from 'types/api/block';
import type { TransactionsResponsePending, TransactionsResponseValidated } from 'types/api/transaction';
import type { TxsResponse } from 'types/api/transaction';
import type { Sort } from 'types/client/txs-sort';
import * as cookies from 'lib/cookies';
import sortTxs from 'lib/tx/sortTxs';
type TxsResponse = TransactionsResponseValidated | TransactionsResponsePending | BlockTransactionsResponse;
type HookResult = UseQueryResult<TxsResponse> & {
sorting: Sort;
setSortByField: (field: 'val' | 'fee') => () => void;
......
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