Commit 61623e29 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #310 from blockscout/homepage

homepage
parents 79d26ea8 54eb25f7
......@@ -64,6 +64,8 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_FOOTER_TWITTER_LINK | `string` *(optional)* | Link to Twitter in the footer | `https://www.twitter.com/blockscoutcom` |
| NEXT_PUBLIC_FOOTER_TELEGRAM_LINK | `string` *(optional)* | Link to Telegram in the footer | `https://t.me/poa_network` |
| NEXT_PUBLIC_FOOTER_STAKING_LINK | `string` *(optional)* | Link to staking dashboard in the footer | `https://duneanalytics.com/maxaleks/xdai-staking` |
| NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER | `boolean` *(optional)* | Set to false if network doesn't have gas tracker | `true` |
| NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME | `boolean` *(optional)* | Set to false if average block time is useless for the network | `true` |
| NEXT_PUBLIC_MARKETPLACE_APP_LIST | `Array<MarketplaceApp>` where `MarketplaceApp` can have following [properties](#marketplace-app-configuration-properties) | List of apps that will be shown on the marketplace page | `[{'author': 'Bob', 'id': 'app', 'title': 'The App', 'logo': 'https://foo.app/icon.png', 'categories': ['security'], 'shortDescription': 'Awesome app', 'site': 'https://foo.app', 'description': 'The best app', 'url': 'https://foo.app/launch'}]` |
| 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'}}]` |
......
......@@ -89,6 +89,8 @@ const config = Object.freeze({
},
homepage: {
charts: parseEnvJson<Array<ChainIndicatorId>>(getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_CHARTS)) || [],
showGasTracker: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER) === 'false' ? false : true,
showAvgBlockTime: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME) === 'false' ? false : true,
},
});
......
......@@ -435,6 +435,7 @@ frontend:
enabled: true
path:
# - "/(apps|auth/profile|account)"
- "/"
- "/apps"
- "/_next"
- "/node-api"
......
......@@ -300,6 +300,7 @@ frontend:
enabled: true
path:
# - "/(apps|auth/profile|account)"
- "/"
- "/apps"
- "/_next"
- "/node-api"
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 29 28">
<g fill="currentColor" clip-path="url(#clock-light_svg__a)">
<path d="M14.75 25.375a11.375 11.375 0 1 1 0-22.75 11.375 11.375 0 0 1 0 22.75Zm0-21a9.625 9.625 0 1 0 0 19.25 9.625 9.625 0 0 0 0-19.25Z"/>
<path d="M19.563 19.688a.875.875 0 0 1-.622-.254L14.13 14.62a.874.874 0 0 1-.254-.621V7a.875.875 0 0 1 1.75 0v6.641l4.559 4.55a.874.874 0 0 1-.622 1.497Z"/>
</g>
<defs>
<clipPath id="clock-light_svg__a">
<path fill="#fff" d="M.75 0h28v28h-28z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 23.75h1.25v2.5H2.5v-2.5h1.25V5A1.25 1.25 0 0 1 5 3.75h11.25A1.25 1.25 0 0 1 17.5 5v10H20a2.5 2.5 0 0 1 2.5 2.5v5a1.25 1.25 0 0 0 2.5 0v-8.75h-2.5a1.25 1.25 0 0 1-1.25-1.25V8.018l-2.071-2.072 1.767-1.767 6.188 6.187a1.244 1.244 0 0 1 .366.884V22.5a3.75 3.75 0 0 1-7.5 0v-5h-2.5v6.25Zm-11.25 0H15v-7.5H6.25v7.5Zm0-17.5v7.5H15v-7.5H6.25Z" fill="currentColor"/>
</svg>
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M12.923 16H3.077A3.078 3.078 0 0 1 0 12.924V4.312a.615.615 0 0 1 .615-.615h12.308A3.077 3.077 0 0 1 16 6.773v6.151A3.076 3.076 0 0 1 12.923 16ZM1.23 4.927v7.997a1.845 1.845 0 0 0 1.846 1.846h9.846a1.846 1.846 0 0 0 1.846-1.846V6.773a1.845 1.845 0 0 0-1.846-1.846H1.23Z"/>
<path d="M14.154 4.927a.616.616 0 0 1-.615-.615V2.62a1.433 1.433 0 0 0-.48-1.15 1.194 1.194 0 0 0-1.028-.197L1.71 3.617a.615.615 0 0 0-.48.615.615.615 0 0 1-1.23 0 1.845 1.845 0 0 1 1.433-1.815L11.76.073a2.401 2.401 0 0 1 2.068.437 2.67 2.67 0 0 1 .941 2.11v1.692a.615.615 0 0 1-.615.615Zm1.231 7.382h-4.308a2.462 2.462 0 1 1 0-4.921h4.308a.615.615 0 0 1 .615.615v3.69a.615.615 0 0 1-.615.616Zm-4.308-3.691a1.231 1.231 0 0 0 0 2.46h3.692v-2.46h-3.692Z"/>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 31 30">
<g fill="currentColor" clip-path="url(#wallet_svg__a)">
<path d="M22.75 27.188h-15A4.688 4.688 0 0 1 3.062 22.5V9.375A.937.937 0 0 1 4 8.437h18.75a4.688 4.688 0 0 1 4.688 4.688V22.5a4.688 4.688 0 0 1-4.688 4.688ZM4.937 10.313V22.5a2.812 2.812 0 0 0 2.813 2.813h15a2.812 2.812 0 0 0 2.813-2.813v-9.375a2.812 2.812 0 0 0-2.813-2.813H4.937Z"/>
<path d="M24.625 10.312a.937.937 0 0 1-.937-.937V6.797a2.184 2.184 0 0 0-.732-1.754 1.82 1.82 0 0 0-1.565-.3L5.669 8.315a.938.938 0 0 0-.731.938.938.938 0 0 1-1.875 0 2.813 2.813 0 0 1 2.184-2.766l15.731-3.572a3.656 3.656 0 0 1 3.15.666 4.069 4.069 0 0 1 1.435 3.216v2.578a.938.938 0 0 1-.938.937ZM26.5 21.563h-6.563a3.75 3.75 0 1 1 0-7.5H26.5a.938.938 0 0 1 .938.937v5.625a.938.938 0 0 1-.938.938Zm-6.563-5.625a1.875 1.875 0 1 0 0 3.75h5.625v-3.75h-5.625Z"/>
</g>
<defs>
<clipPath id="wallet_svg__a">
<path fill="#fff" d="M.25 0h30v30h-30z"/>
</clipPath>
</defs>
</svg>
import BigNumber from 'bignumber.js';
import type { Block } from 'types/api/block';
import { WEI, ZERO } from 'lib/consts';
export default function getBlockTotalReward(block: Block) {
const totalReward = block.rewards
?.map(({ reward }) => BigNumber(reward))
.reduce((result, item) => result.plus(item), ZERO) || ZERO;
return totalReward.div(WEI).toFixed();
}
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import React from 'react';
import { ROUTES } from 'lib/link/routes';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
function getSocketParams(router: NextRouter) {
if (
router.pathname === ROUTES.txs.pattern &&
(router.query.tab === 'validated' || router.query.tab === undefined) &&
!router.query.block_number
) {
return { topic: 'transactions:new_transaction' as const, event: 'transaction' as const };
}
if (router.pathname === ROUTES.network_index.pattern) {
return { topic: 'transactions:new_transaction' as const, event: 'transaction' as const };
}
if (router.pathname === ROUTES.txs.pattern && router.query.tab === 'pending' && !router.query.block_number) {
return { topic: 'transactions:new_pending_transaction' as const, event: 'pending_transaction' as const };
}
return {};
}
function assertIsNewTxResponse(response: unknown): response is { transaction: number } {
return typeof response === 'object' && response !== null && 'transaction' in response;
}
function assertIsNewPendingTxResponse(response: unknown): response is { pending_transaction: number } {
return typeof response === 'object' && response !== null && 'pending_transaction' in response;
}
export default function useNewTxsSocket() {
const router = useRouter();
const [ num, setNum ] = React.useState(0);
const [ socketAlert, setSocketAlert ] = React.useState('');
const { topic, event } = getSocketParams(router);
const handleNewTxMessage = React.useCallback((response: { transaction: number } | { pending_transaction: number } | unknown) => {
if (assertIsNewTxResponse(response)) {
setNum((prev) => prev + response.transaction);
}
if (assertIsNewPendingTxResponse(response)) {
setNum((prev) => prev + response.pending_transaction);
}
}, []);
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please click here to load new transactions.');
}, []);
const handleSocketError = React.useCallback(() => {
setSocketAlert('An error has occurred while fetching new transactions. Please click here to refresh the page.');
}, []);
const channel = useSocketChannel({
topic,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: !topic,
});
useSocketMessage({
channel,
event,
handler: handleNewTxMessage,
});
if (!topic && !event) {
return {};
}
return { num, socketAlert };
}
import handler from 'lib/api/handler';
const getUrl = () => '/v2/main-page/blocks';
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import handler from 'lib/api/handler';
const getUrl = () => '/v2/stats';
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import handler from 'lib/api/handler';
const getUrl = () => '/v2/main-page/transactions';
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import Vercel from 'ui/pages/Vercel';
const VercelPage: NextPage = () => {
return (
<>
<Head><title>Vercel Page</title></Head>
<Vercel/>
</>
);
};
export default VercelPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
......@@ -14,6 +14,11 @@ const sizes = {
fontSize: '48px',
lineHeight: '60px',
}),
xl: defineStyle({
fontSize: '40px',
lineHeight: '48px',
letterSpacing: '-1px',
}),
lg: defineStyle({
fontSize: '32px',
lineHeight: '40px',
......
......@@ -14,4 +14,6 @@ export enum QueryKeys {
blocks = 'blocks',
chartsTxs = 'charts-txs',
chartsMarket = 'charts-market',
indexBlocks='indexBlocks',
indexTxs='indexTxs',
}
import { Text } from '@chakra-ui/react';
import type { TypographyProps } from '@chakra-ui/react';
import React from 'react';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
......@@ -6,12 +7,13 @@ import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
interface Props {
ts: string;
isEnabled?: boolean;
fontSize?: TypographyProps['fontSize'];
}
const BlockTimestamp = ({ ts, isEnabled }: Props) => {
const BlockTimestamp = ({ ts, isEnabled, fontSize }: Props) => {
const timeAgo = useTimeAgoIncrement(ts, isEnabled);
return <Text variant="secondary" fontWeight={ 400 }>{ timeAgo }</Text>;
return <Text variant="secondary" fontWeight={ 400 } fontSize={ fontSize }>{ timeAgo }</Text>;
};
export default React.memo(BlockTimestamp);
......@@ -7,7 +7,8 @@ import type { Block } from 'types/api/block';
import appConfig from 'configs/app/config';
import flameIcon from 'icons/flame.svg';
import { WEI, ZERO } from 'lib/consts';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts';
import link from 'lib/link/link';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
......@@ -23,9 +24,7 @@ interface Props {
}
const BlocksListItem = ({ data, isPending, enableTimeIncrement }: Props) => {
const totalReward = data.rewards
?.map(({ reward }) => BigNumber(reward))
.reduce((result, item) => result.plus(item), ZERO) || ZERO;
const totalReward = getBlockTotalReward(data);
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0);
......@@ -65,7 +64,7 @@ const BlocksListItem = ({ data, isPending, enableTimeIncrement }: Props) => {
</Box>
<Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>Reward { appConfig.network.currency.symbol }</Text>
<Text variant="secondary">{ totalReward.div(WEI).toFixed() }</Text>
<Text variant="secondary">{ totalReward }</Text>
</Flex>
<Box>
<Text fontWeight={ 500 }>Burnt fees</Text>
......
......@@ -6,7 +6,8 @@ import React from 'react';
import type { Block } from 'types/api/block';
import flameIcon from 'icons/flame.svg';
import { WEI, ZERO } from 'lib/consts';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts';
import link from 'lib/link/link';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import AddressLink from 'ui/shared/address/AddressLink';
......@@ -20,9 +21,7 @@ interface Props {
}
const BlocksTableItem = ({ data, isPending, enableTimeIncrement }: Props) => {
const totalReward = data.rewards
?.map(({ reward }) => BigNumber(reward))
.reduce((result, item) => result.plus(item), ZERO) || ZERO;
const totalReward = getBlockTotalReward(data);
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0);
......@@ -61,7 +60,7 @@ const BlocksTableItem = ({ data, isPending, enableTimeIncrement }: Props) => {
<GasUsedToTargetRatio ml={ 2 } value={ data.gas_target_percentage || undefined }/>
</Flex>
</Td>
<Td fontSize="sm">{ totalReward.dividedBy(WEI).toFixed() }</Td>
<Td fontSize="sm">{ totalReward }</Td>
<Td fontSize="sm">
<Flex alignItems="center" columnGap={ 1 }>
<Icon as={ flameIcon } boxSize={ 5 } color={ useColorModeValue('gray.500', 'inherit') }/>
......
import { Box, Heading, Flex, Link, Text, VStack, Skeleton } from '@chakra-ui/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence } from 'framer-motion';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { Block } from 'types/api/block';
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 link from 'lib/link/link';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import LatestBlocksItem from './LatestBlocksItem';
import LatestBlocksItemSkeleton from './LatestBlocksItemSkeleton';
const BLOCK_HEIGHT = 166;
const BLOCK_MARGIN = 24;
const LatestBlocks = () => {
const isMobile = useIsMobile();
const blocksMaxCount = isMobile ? 2 : 4;
const fetch = useFetch();
const { data, isLoading, isError } = useQuery<unknown, unknown, Array<Block>>(
[ QueryKeys.indexBlocks ],
async() => await fetch(`/api/index/blocks`),
);
const queryClient = useQueryClient();
const handleNewBlockMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => {
queryClient.setQueryData([ QueryKeys.indexBlocks ], (prevData: Array<Block> | undefined) => {
const newData = prevData ? [ ...prevData ] : [];
return [ payload.block, ...newData ].slice(0, blocksMaxCount);
});
}, [ queryClient, blocksMaxCount ]);
const channel = useSocketChannel({
topic: 'blocks:new_block',
isDisabled: isLoading || isError,
});
useSocketMessage({
channel,
event: 'new_block',
handler: handleNewBlockMessage,
});
let content;
if (isLoading) {
content = (
<>
<Skeleton w="100%" h={ 6 } mb={ 9 }/>
<VStack
spacing={ `${ BLOCK_MARGIN }px` }
mb={ 6 }
height={ `${ BLOCK_HEIGHT * blocksMaxCount + BLOCK_MARGIN * (blocksMaxCount - 1) }px` }
overflow="hidden"
>
{ Array.from(Array(blocksMaxCount)).map((item, index) => <LatestBlocksItemSkeleton key={ index }/>) }
</VStack>
</>
);
}
if (isError) {
content = <Text>No data. Please reload page.</Text>;
}
if (data) {
const dataToShow = data.slice(0, blocksMaxCount);
const blocksCount = dataToShow.length;
content = (
<>
<Box mb={{ base: 6, lg: 9 }}>
<Text as="span" fontSize="sm">
Network utilization:{ nbsp }
</Text>
{ /* Not implemented in API yet */ }
<Text as="span" fontSize="sm" color="blue.400" fontWeight={ 700 }>
43.8%
</Text>
</Box>
<VStack spacing={ `${ BLOCK_MARGIN }px` } mb={ 6 } height={ `${ BLOCK_HEIGHT * blocksCount + BLOCK_MARGIN * (blocksCount - 1) }px` } overflow="hidden">
<AnimatePresence initial={ false } >
{ dataToShow.map((block => <LatestBlocksItem key={ block.height } block={ block } h={ BLOCK_HEIGHT }/>)) }
</AnimatePresence>
</VStack>
<Flex justifyContent="center">
<Link fontSize="sm" href={ link('blocks') }>View all blocks</Link>
</Flex>
</>
);
}
return (
<>
<Heading as="h4" fontSize="18px" mb={{ base: 3, lg: 8 }}>Latest Blocks</Heading>
{ content }
</>
);
};
export default LatestBlocks;
import {
Box,
Flex,
Grid,
GridItem,
HStack,
Icon,
Link,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import React from 'react';
import type { Block } from 'types/api/block';
import blockIcon from 'icons/block.svg';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import link from 'lib/link/link';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import AddressLink from 'ui/shared/address/AddressLink';
type Props = {
block: Block;
h: number;
}
const LatestBlocksItem = ({ block, h }: Props) => {
const totalReward = getBlockTotalReward(block);
return (
<Box
as={ motion.div }
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transitionDuration="normal"
transitionTimingFunction="linear"
borderRadius="12px"
border="1px solid"
borderColor={ useColorModeValue('gray.200', 'whiteAlpha.200') }
p={ 6 }
h={ `${ h }px` }
minWidth={{ base: '100%', lg: '280px' }}
>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
<HStack spacing={ 2 }>
<Icon as={ blockIcon } boxSize="30px" color={ useColorModeValue('blue.600', 'blue.300') }/>
<Link
href={ link('block', { id: String(block.height) }) }
fontSize="xl"
fontWeight="500"
>
{ block.height }
</Link>
</HStack>
<BlockTimestamp ts={ block.timestamp } isEnabled fontSize="sm"/>
</Flex>
<Grid gridGap={ 2 } templateColumns="auto minmax(0, 1fr)" fontSize="sm">
<GridItem>Txn</GridItem>
<GridItem><Text variant="secondary">{ block.tx_count }</Text></GridItem>
{ /* */ }
<GridItem>Reward</GridItem>
<GridItem><Text variant="secondary">{ totalReward }</Text></GridItem>
<GridItem>Miner</GridItem>
<GridItem><AddressLink alias={ block.miner.name } hash={ block.miner.hash } truncation="constant" maxW="100%"/></GridItem>
</Grid>
</Box>
);
};
export default LatestBlocksItem;
import {
Box,
Flex,
Grid,
GridItem,
HStack,
Skeleton,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
const LatestBlocksItemSkeleton = () => {
return (
<Box
minWidth={{ base: '100%', lg: '280px' }}
borderRadius="12px"
border="1px solid"
borderColor={ useColorModeValue('gray.200', 'whiteAlpha.200') }
p={ 6 }
>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
<HStack spacing={ 2 }>
<Skeleton w="30px" h="30px"/>
<Skeleton w="93px" h="15px"/>
</HStack>
<Skeleton w="44px" h="15px"/>
</Flex>
<Grid gridGap={ 2 } templateColumns="auto minmax(0, 1fr)" fontSize="sm">
<GridItem><Skeleton w="30px" h="15px"/></GridItem>
<GridItem><Skeleton w="93px" h="15px"/></GridItem>
<GridItem><Skeleton w="30px" h="15px"/></GridItem>
<GridItem><Skeleton w="93px" h="15px"/></GridItem>
<GridItem><Skeleton w="30px" h="15px"/></GridItem>
<GridItem><Skeleton w="93px" h="15px"/></GridItem>
</Grid>
</Box>
);
};
export default LatestBlocksItemSkeleton;
import { Box, Heading, Flex, Link, Text, Skeleton } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { Transaction } from 'types/api/transaction';
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 LatestTxsItem from './LatestTxsItem';
import LatestTxsItemSkeleton from './LatestTxsItemSkeleton';
import LatestTxsNotice from './LatestTxsNotice';
const LatestTransactions = () => {
const isMobile = useIsMobile();
const txsCount = isMobile ? 2 : 6;
const fetch = useFetch();
const { data, isLoading, isError } = useQuery<unknown, unknown, Array<Transaction>>(
[ QueryKeys.indexTxs ],
async() => await fetch(`/api/index/txs`),
);
let content;
if (isLoading) {
content = (
<>
<Skeleton h="56px" w="100%"/>
{ Array.from(Array(txsCount)).map((item, index) => <LatestTxsItemSkeleton key={ index }/>) }
</>
);
}
if (isError) {
content = <Text mt={ 4 }>No data. Please reload page.</Text>;
}
if (data) {
const txsUrl = link('txs');
content = (
<>
<LatestTxsNotice/>
<Box mb={{ base: 3, lg: 6 }}>
{ data.slice(0, txsCount).map((tx => <LatestTxsItem key={ tx.hash } tx={ tx }/>)) }
</Box>
<Flex justifyContent="center">
<Link fontSize="sm" href={ txsUrl }>View all transactions</Link>
</Flex>
</>
);
}
return (
<>
<Heading as="h4" fontSize="18px" mb={{ base: 3, lg: 8 }}>Latest transactions</Heading>
{ content }
</>
);
};
export default LatestTransactions;
import {
Box,
Flex,
HStack,
Icon,
Modal,
ModalContent,
ModalCloseButton,
Text,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import type { Transaction } from 'types/api/transaction';
import appConfig from 'configs/app/config';
import rightArrowIcon from 'icons/arrows/east.svg';
import transactionIcon from 'icons/transactions.svg';
import getValueWithUnit from 'lib/getValueWithUnit';
import useIsMobile from 'lib/hooks/useIsMobile';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import AdditionalInfoButton from 'ui/shared/AdditionalInfoButton';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import TxStatus from 'ui/shared/TxStatus';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
import TxType from 'ui/txs/TxType';
type Props = {
tx: Transaction;
}
const LatestBlocksItem = ({ tx }: Props) => {
const borderColor = useColorModeValue('gray.200', 'whiteAlpha.200');
const iconColor = useColorModeValue('blue.600', 'blue.300');
const dataTo = tx.to && tx.to.hash ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true);
const isMobile = useIsMobile();
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<Box
width="100%"
borderTop="1px solid"
borderColor={ borderColor }
py={{ base: 4, lg: 6 }}
px={{ base: 0, lg: 4 }}
_last={{ borderBottom: '1px solid', borderColor }}
>
<Flex justifyContent="space-between" width="100%" alignItems="start" flexDirection={{ base: 'column', lg: 'row' }}>
{ !isMobile && (
<Popover placement="right-start" openDelay={ 300 } isLazy>
{ ({ isOpen }) => (
<>
<PopoverTrigger>
<AdditionalInfoButton isOpen={ isOpen } mr={ 3 }/>
</PopoverTrigger>
<PopoverContent border="1px solid" borderColor={ borderColor }>
<PopoverBody>
<TxAdditionalInfo tx={ tx }/>
</PopoverBody>
</PopoverContent>
</>
) }
</Popover>
) }
<Box width={{ base: '100%', lg: 'calc(50% - 32px)' }}>
<Flex justifyContent="space-between">
<HStack>
<TxType types={ tx.tx_types }/>
<TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined }/>
</HStack>
{ isMobile && <AdditionalInfoButton onClick={ onOpen }/> }
</Flex>
<Flex
mt={ 2 }
alignItems="center"
width="100%"
justifyContent={{ base: 'space-between', lg: 'start' }}
mb={{ base: 6, lg: 0 }}
>
<Flex mr={ 3 }>
<Icon
as={ transactionIcon }
boxSize="30px"
mr={ 2 }
color={ iconColor }
/>
<Address width="100%">
<AddressLink
hash={ tx.hash }
type="transaction"
fontWeight="700"
truncation="constant"
target="_self"
/>
</Address>
</Flex>
{ tx.timestamp && <Text variant="secondary" fontWeight="400" fontSize="sm">{ timeAgo }</Text> }
</Flex>
</Box>
<Box width={{ base: '100%', lg: '50%' }}>
<Flex alignItems="center" mb={ 3 } justifyContent={{ base: 'start', lg: 'end' }}>
<Address>
<AddressIcon hash={ tx.from.hash }/>
<AddressLink
hash={ tx.from.hash }
alias={ tx.from.name }
fontWeight="500"
ml={ 2 }
truncation="constant"
fontSize="sm"
/>
</Address>
<Icon
as={ rightArrowIcon }
boxSize={ 6 }
mx={ 2 }
color="gray.500"
/>
<Address>
<AddressIcon hash={ dataTo.hash }/>
<AddressLink
hash={ dataTo.hash }
alias={ dataTo.name }
fontWeight="500"
ml={ 2 }
truncation="constant"
fontSize="sm"
/>
</Address>
</Flex>
<Flex fontSize="sm" justifyContent="end" flexDirection={{ base: 'column', lg: 'row' }}>
<Box mr={{ base: 0, lg: 2 }} mb={{ base: 2, lg: 0 }}>
<Text as="span">Value { appConfig.network.currency.symbol } </Text>
<Text as="span" variant="secondary">{ getValueWithUnit(tx.value).toFormat() }</Text>
</Box>
<Box>
<Text as="span">Fee { appConfig.network.currency.symbol } </Text>
<Text as="span" variant="secondary">{ getValueWithUnit(tx.fee.value).toFormat() }</Text>
</Box>
</Flex>
</Box>
</Flex>
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent paddingTop={ 4 }>
<ModalCloseButton/>
<TxAdditionalInfo tx={ tx }/>
</ModalContent>
</Modal>
</Box>
);
};
export default LatestBlocksItem;
import {
Box,
Flex,
HStack,
Skeleton,
SkeletonCircle,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
const LatestTxsItemSkeleton = () => {
const borderColor = useColorModeValue('gray.200', 'whiteAlpha.200');
return (
<Box
width="100%"
borderTop="1px solid"
borderColor={ borderColor }
py={{ base: 4, lg: 6 }}
px={{ base: 0, lg: 4 }}
_last={{ borderBottom: '1px solid', borderColor }}
>
<Flex justifyContent="space-between" width="100%" alignItems="start" flexDirection={{ base: 'column', lg: 'row' }}>
<Box width="100%">
<HStack spacing={ 2 }>
<Skeleton w="101px" h="24px"/>
<Skeleton w="101px" h="24px"/>
</HStack>
<Flex
mt={ 2 }
alignItems="center"
width="100%"
justifyContent={{ base: 'space-between', lg: 'start' }}
mb={{ base: 6, lg: 0 }}
>
<Flex mr={ 3 } alignItems="center">
<Skeleton w="30px" h="30px" mr={ 2 }/>
<Skeleton w="101px" h="12px"/>
</Flex>
<Skeleton w="40px" h="12px"/>
</Flex>
</Box>
<Box>
<Flex alignItems="center" mb={ 3 }>
<SkeletonCircle w="30px" h="30px" mr={ 2 }/>
<Skeleton w="101px" h="12px" mr={ 5 }/>
<SkeletonCircle w="30px" h="30px" mr={ 2 }/>
<Skeleton w="101px" h="12px"/>
</Flex>
<Flex fontSize="sm" mt={ 3 } justifyContent="end" flexDirection={{ base: 'column', lg: 'row' }}>
<Skeleton w="123px" h="12px" mr={{ base: 0, lg: 9 }} mb={{ base: 2, lg: 0 }}/>
<Skeleton w="123px" h="12px"/>
</Flex>
</Box>
</Flex>
</Box>
);
};
export default LatestTxsItemSkeleton;
import { Alert, Spinner, 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 = (
<>
<Spinner size="sm" mr={ 3 }/>
<Text>scanning new transactions ...</Text>
</>
);
} else {
const txsUrl = link('txs');
content = (
<>
<Spinner size="sm" mr={ 3 }/>
<Text as="span" whiteSpace="pre">+ { num } new transaction{ num > 1 ? 's' : '' }. </Text>
<Link href={ txsUrl }>Show in list</Link>
</>
);
}
const theme = useTheme();
return (
<Alert
className={ className }
status="warning"
p={ 4 }
fontWeight={ 400 }
bgColor={ useColorModeValue('orange.50', transparentize('orange.200', 0.16)(theme)) }
borderBottomRadius={ 0 }
>
{ content }
</Alert>
);
};
export default LatestTxsNotice;
import { Grid } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import { Stats } from 'types/api/stats';
import { QueryKeys } from 'types/client/queries';
import appConfig from 'configs/app/config';
import blockIcon from 'icons/block.svg';
import clockIcon from 'icons/clock-light.svg';
import gasIcon from 'icons/gas.svg';
import txIcon from 'icons/transactions.svg';
import walletIcon from 'icons/wallet.svg';
import useFetch from 'lib/hooks/useFetch';
import StatsItem from './StatsItem';
import StatsItemSkeleton from './StatsItemSkeleton';
const hasGasTracker = appConfig.homepage.showGasTracker;
const hasAvgBlockTime = appConfig.homepage.showAvgBlockTime;
let itemsCount = 5;
!hasGasTracker && itemsCount--;
!hasAvgBlockTime && itemsCount--;
const Stats = () => {
const fetch = useFetch();
const { data, isLoading, isError } = useQuery<unknown, unknown, Stats>(
[ QueryKeys.stats ],
async() => await fetch(`/api/index/stats`),
);
if (isError) {
return null;
}
let content;
if (isLoading) {
content = Array.from(Array(itemsCount)).map((item, index) => <StatsItemSkeleton key={ index }/>);
}
if (data) {
content = (
<>
<StatsItem
icon={ blockIcon }
title="Total blocks"
value={ Number(data.total_blocks).toLocaleString() }
/>
{ hasAvgBlockTime && (
<StatsItem
icon={ clockIcon }
title="Average block time"
value={ `${ (data.average_block_time / 1000).toFixed(1) } s` }
/>
) }
<StatsItem
icon={ txIcon }
title="Total transactions"
value={ Number(data.total_transactions).toLocaleString() }
/>
<StatsItem
icon={ walletIcon }
title="Wallet addresses"
value={ Number(data.total_addresses).toLocaleString() }
/>
{ hasGasTracker && (
<StatsItem
icon={ gasIcon }
title="Gas tracker"
value={ `${ Number(data.gas_prices.average).toLocaleString() } Gwei` }
/>
) }
</>
);
}
return (
<Grid
gridTemplateColumns={{ lg: `repeat(${ itemsCount }, 1fr)`, base: 'none' }}
gridTemplateRows={{ lg: 'none', base: `repeat(${ itemsCount }, 1fr)` }}
gridGap="10px"
marginTop="32px"
>
{ content }
</Grid>
);
};
export default Stats;
import { Flex, Icon, Center, Text, LightMode } from '@chakra-ui/react';
import React from 'react';
type Props = {
icon: React.FC<React.SVGAttributes<SVGElement>>;
title: string;
value: string;
}
const StatsItem = ({ icon, title, value }: Props) => {
return (
<LightMode>
<Flex
backgroundColor="blue.50"
padding={ 5 }
borderRadius="16px"
flexDirection={{ base: 'row', lg: 'column', xl: 'row' }}
alignItems="center"
>
<Center
backgroundColor="green.100"
borderRadius="12px"
w={ 10 }
h={ 10 }
mr={{ base: 4, lg: 0, xl: 4 }}
mb={{ base: 0, lg: 2, xl: 0 }}
>
<Icon as={ icon } boxSize={ 7 } color="black"/>
</Center>
<Flex flexDirection="column" alignItems={{ base: 'start', lg: 'center', xl: 'start' }}>
<Text variant="secondary" fontSize="xs" lineHeight="16px">{ title }</Text>
<Text fontWeight={ 500 } fontSize="md">{ value }</Text>
</Flex>
</Flex>
</LightMode>
);
};
export default StatsItem;
import { Flex, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
const StatsItemSkeleton = () => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
return (
<Flex
backgroundColor={ bgColor }
padding={ 5 }
borderRadius="16px"
flexDirection={{ base: 'row', lg: 'column', xl: 'row' }}
alignItems="center"
>
<Skeleton
w="40px"
h="40px"
mr={{ base: 4, lg: 0, xl: 4 }}
mb={{ base: 0, lg: 2, xl: 0 }}
/>
<Flex flexDirection="column" alignItems={{ base: 'start', lg: 'center', xl: 'start' }}>
<Skeleton w="69px" h="10px" mt="4px" mb="8px"/>
<Skeleton w="93px" h="14px" mb="4px"/>
</Flex>
</Flex>
);
};
export default StatsItemSkeleton;
import { useToken } from '@chakra-ui/react';
import React from 'react';
import type { ChainIndicatorChartData } from './types';
......@@ -8,7 +9,6 @@ import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartTooltip from 'ui/shared/chart/ChartTooltip';
import useChartSize from 'ui/shared/chart/useChartSize';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import { BlueLineGradient } from 'ui/shared/chart/utils/gradients';
interface Props {
data: ChainIndicatorChartData;
......@@ -20,6 +20,7 @@ const CHART_MARGIN = { bottom: 0, left: 10, right: 10, top: 0 };
const ChainIndicatorChart = ({ data }: Props) => {
const ref = React.useRef<SVGSVGElement>(null);
const overlayRef = React.useRef<SVGRectElement>(null);
const lineColor = useToken('colors', 'blue.500');
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN);
const { xScale, yScale } = useTimeChartController({
......@@ -30,13 +31,9 @@ const ChainIndicatorChart = ({ data }: Props) => {
return (
<svg width={ width || '100%' } height={ height || '100%' } ref={ ref } cursor="pointer">
<defs>
<BlueLineGradient.defs/>
</defs>
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ width ? 1 : 0 }>
<ChartArea
data={ data[0].items }
color={ data[0].color }
xScale={ xScale }
yScale={ yScale }
/>
......@@ -44,7 +41,7 @@ const ChainIndicatorChart = ({ data }: Props) => {
data={ data[0].items }
xScale={ xScale }
yScale={ yScale }
stroke={ `url(#${ BlueLineGradient.id })` }
stroke={ lineColor }
animation="left"
strokeWidth={ 3 }
/>
......
import { Flex } from '@chakra-ui/react';
import { Flex, Spinner } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { ChainIndicatorChartData } from './types';
import ChartLineLoader from 'ui/shared/chart/ChartLineLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ChainIndicatorChart from './ChainIndicatorChart';
......@@ -15,7 +14,7 @@ const ChainIndicatorChartContainer = ({ data, isError, isLoading }: Props) => {
const content = (() => {
if (isLoading) {
return <ChartLineLoader mt="auto"/>;
return <Spinner size="md" m="auto"/>;
}
if (isError) {
......@@ -25,7 +24,7 @@ const ChainIndicatorChartContainer = ({ data, isError, isLoading }: Props) => {
return <ChainIndicatorChart data={ data }/>;
})();
return <Flex h={{ base: '150px', lg: '250px' }} alignItems="flex-start">{ content }</Flex>;
return <Flex h={{ base: '150px', lg: 'auto' }} minH="150px" alignItems="flex-start" flexGrow={ 1 }>{ content }</Flex>;
};
export default React.memo(ChainIndicatorChartContainer);
......@@ -31,7 +31,15 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats
}
if (stats.isLoading) {
return <Skeleton h={ 3 } w="70px" my={ 1.5 }/>;
return (
<Skeleton
h={ 3 }
w="70px"
my={ 1.5 }
// ssr: isMobile = undefined, isLoading = true
display={{ base: 'none', lg: 'block' }}
/>
);
}
if (stats.isError) {
......
......@@ -74,6 +74,7 @@ const ChainIndicators = () => {
flexDir={{ base: 'column', lg: 'row' }}
w="100%"
alignItems="stretch"
mt={ 8 }
>
<Flex flexGrow={ 1 } flexDir="column" order={{ base: 2, lg: 1 }} p={{ base: 6, lg: 0 }}>
<Flex alignItems="center">
......
......@@ -10,7 +10,6 @@ import txIcon from 'icons/transactions.svg';
import { shortenNumberWithLetter } from 'lib/formatters';
import { sortByDateDesc } from 'ui/shared/chart/utils/sorts';
import TokenLogo from 'ui/shared/TokenLogo';
const CHART_COLOR = '#439AE2';
const dailyTxsIndicator: TChainIndicator<QueryKeys.chartsTxs> = {
id: 'daily_txs',
......@@ -26,7 +25,6 @@ const dailyTxsIndicator: TChainIndicator<QueryKeys.chartsTxs> = {
.map((item) => ({ date: new Date(item.date), value: item.tx_count }))
.sort(sortByDateDesc),
name: 'Tx/day',
color: CHART_COLOR,
valueFormatter: (x) => shortenNumberWithLetter(x, undefined, { maximumFractionDigits: 2 }),
} ]),
},
......@@ -46,7 +44,6 @@ const coinPriceIndicator: TChainIndicator<QueryKeys.chartsMarket> = {
.map((item) => ({ date: new Date(item.date), value: Number(item.closing_price) }))
.sort(sortByDateDesc),
name: `${ appConfig.network.currency.symbol } price`,
color: CHART_COLOR,
valueFormatter: (x) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
} ]),
},
......@@ -67,7 +64,6 @@ const marketPriceIndicator: TChainIndicator<QueryKeys.chartsMarket> = {
.map((item) => ({ date: new Date(item.date), value: Number(item.closing_price) * Number(response.available_supply) }))
.sort(sortByDateDesc),
name: 'Market cap',
color: CHART_COLOR,
valueFormatter: (x) => '$' + shortenNumberWithLetter(x, undefined, { maximumFractionDigits: 0 }),
} ]),
},
......
import { VStack, Textarea, Button, Alert, AlertTitle, AlertDescription, Link, Code } from '@chakra-ui/react';
import * as Sentry from '@sentry/react';
import type { ChangeEvent } from 'react';
import { Box, Heading, Flex, LightMode } from '@chakra-ui/react';
import React from 'react';
import appConfig from 'configs/app/config';
import * as cookies from 'lib/cookies';
import useToast from 'lib/hooks/useToast';
import ChainIndicators from 'ui/home/indicators/ChainIndicators';
import LatestBlocks from 'ui/home/LatestBlocks';
import LatestTxs from 'ui/home/LatestTxs';
import Stats from 'ui/home/Stats';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
const Home = () => {
const toast = useToast();
const [ isFormVisible, setFormVisibility ] = React.useState(false);
const [ token, setToken ] = React.useState('');
React.useEffect(() => {
const token = cookies.get(cookies.NAMES.API_TOKEN);
setFormVisibility(Boolean(!token && appConfig.isAccountSupported));
}, []);
const checkSentry = React.useCallback(() => {
Sentry.captureException(new Error('Test error'), { extra: { foo: 'bar' }, tags: { source: 'test' } });
}, []);
const handleTokenChange = React.useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
setToken(event.target.value);
}, []);
const handleSetTokenClick = React.useCallback(() => {
cookies.set(cookies.NAMES.API_TOKEN, token);
setToken('');
toast({
position: 'top-right',
title: 'Success 🥳',
description: 'Successfully set cookie',
status: 'success',
variant: 'subtle',
isClosable: true,
onCloseComplete: () => {
setFormVisibility(false);
},
});
}, [ toast, token ]);
const prodUrl = 'https://blockscout.com/poa/core';
return (
<Page>
<VStack gap={ 4 } alignItems="flex-start" maxW="1000px">
<PageTitle text={
`Home Page for ${ appConfig.network.name } network`
}/>
<ChainIndicators/>
{ /* will be deleted when we move to new CI */ }
{ isFormVisible && (
<>
<Alert status="error" flexDirection="column" alignItems="flex-start">
<AlertTitle fontSize="md">
!!! Temporary solution for authentication !!!
</AlertTitle>
<AlertDescription mt={ 3 }>
To Sign in go to <Link href={ prodUrl } target="_blank">{ prodUrl }</Link> first, sign in there, copy obtained API token from cookie
<Code ml={ 1 }>{ cookies.NAMES.API_TOKEN }</Code> and paste it in the form below. After submitting the form you should be successfully
authenticated in current environment
</AlertDescription>
</Alert>
<Textarea value={ token } onChange={ handleTokenChange } placeholder="API token"/>
<Button onClick={ handleSetTokenClick }>Set cookie</Button>
</>
) }
<Button colorScheme="red" onClick={ checkSentry }>Check Sentry</Button>
</VStack>
<Page hasSearch={ false }>
<Box
w="100%"
backgroundImage="radial-gradient(farthest-corner at 0 0, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%)"
borderRadius="24px"
padding={{ base: '24px 40px', lg: '48px' }}
minW={{ base: 'unset', lg: '900px' }}
>
<Heading
as="h1"
size={{ base: 'lg', ld: 'xl' }}
fontWeight={{ base: 600, lg: 500 }}
color="white"
mb={{ base: 6, lg: 8 }}
>
Welcome to Blockscout explorer
</Heading>
<LightMode><SearchBar isHomepage/></LightMode>
</Box>
<Stats/>
<ChainIndicators/>
<Flex mt={ 12 } direction={{ base: 'column', lg: 'row' }}>
<Box mr={{ base: 0, lg: 12 }} mb={{ base: 8, lg: 0 }} width={{ base: '100%', lg: '280px' }}><LatestBlocks/></Box>
<Box flexGrow={ 1 }><LatestTxs/></Box>
</Flex>
</Page>
);
};
......
import { VStack, Textarea, Button, Alert, AlertTitle, AlertDescription, Link, Code } 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 useToast from 'lib/hooks/useToast';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
{ /* will be deleted when we move to new CI */ }
const Vercel = () => {
const toast = useToast();
const [ isFormVisible, setFormVisibility ] = React.useState(false);
const [ token, setToken ] = React.useState('');
React.useEffect(() => {
const token = cookies.get(cookies.NAMES.API_TOKEN);
setFormVisibility(Boolean(!token && appConfig.isAccountSupported));
}, []);
const checkSentry = React.useCallback(() => {
Sentry.captureException(new Error('Test error'), { extra: { foo: 'bar' }, tags: { source: 'test' } });
}, []);
const handleTokenChange = React.useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
setToken(event.target.value);
}, []);
const handleSetTokenClick = React.useCallback(() => {
cookies.set(cookies.NAMES.API_TOKEN, token);
setToken('');
toast({
position: 'top-right',
title: 'Success 🥳',
description: 'Successfully set cookie',
status: 'success',
variant: 'subtle',
isClosable: true,
onCloseComplete: () => {
setFormVisibility(false);
},
});
}, [ toast, token ]);
const prodUrl = 'https://blockscout.com/poa/core';
return (
<Page>
<VStack gap={ 4 } alignItems="flex-start" maxW="1000px">
<PageTitle text="Vercel page"/>
{ isFormVisible && (
<>
<Alert status="error" flexDirection="column" alignItems="flex-start">
<AlertTitle fontSize="md">
!!! Temporary solution for authentication !!!
</AlertTitle>
<AlertDescription mt={ 3 }>
To Sign in go to <Link href={ prodUrl } target="_blank">{ prodUrl }</Link> first, sign in there, copy obtained API token from cookie
<Code ml={ 1 }>{ cookies.NAMES.API_TOKEN }</Code> and paste it in the form below. After submitting the form you should be successfully
authenticated in current environment
</AlertDescription>
</Alert>
<Textarea value={ token } onChange={ handleTokenChange } placeholder="API token"/>
<Button onClick={ handleSetTokenClick }>Set cookie</Button>
</>
) }
<Button colorScheme="red" onClick={ checkSentry }>Check Sentry</Button>
</VStack>
</Page>
);
};
export default Vercel;
......@@ -20,12 +20,14 @@ interface Props {
children: React.ReactNode;
wrapChildren?: boolean;
hideMobileHeaderOnScrollDown?: boolean;
hasSearch?: boolean;
}
const Page = ({
children,
wrapChildren = true,
hideMobileHeaderOnScrollDown,
hasSearch = true,
}: Props) => {
const fetch = useFetch();
......@@ -37,12 +39,12 @@ const Page = ({
const renderErrorScreen = React.useCallback(() => {
return wrapChildren ?
<PageContent><AppError statusCode={ 500 } mt="50px"/></PageContent> :
<PageContent hasSearch={ hasSearch }><AppError statusCode={ 500 } mt="50px"/></PageContent> :
<AppError statusCode={ 500 }/>;
}, [ wrapChildren ]);
}, [ wrapChildren, hasSearch ]);
const renderedChildren = wrapChildren ? (
<PageContent>{ children }</PageContent>
<PageContent hasSearch={ hasSearch }>{ children }</PageContent>
) : children;
return (
......@@ -51,7 +53,7 @@ const Page = ({
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Flex flexDir="column" width="100%">
<Header hideOnScrollDown={ hideMobileHeaderOnScrollDown }/>
<Header hasSearch={ hasSearch } hideOnScrollDown={ hideMobileHeaderOnScrollDown }/>
<ErrorBoundary renderErrorScreen={ renderErrorScreen }>
{ renderedChildren }
</ErrorBoundary>
......
......@@ -3,16 +3,17 @@ import React from 'react';
interface Props {
children: React.ReactNode;
hasSearch: boolean;
}
const PageContent = ({ children }: Props) => {
const PageContent = ({ children, hasSearch }: Props) => {
return (
<Box
as="main"
w="100%"
paddingX={{ base: 4, lg: 12 }}
paddingBottom={ 10 }
paddingTop={{ base: '138px', lg: 0 }}
paddingTop={{ base: hasSearch ? '138px' : '88px', lg: 0 }}
>
{ children }
</Box>
......
import { useColorModeValue, useToken } from '@chakra-ui/react';
import { useColorModeValue, useToken, useTheme } from '@chakra-ui/react';
import { transparentize } from '@chakra-ui/theme-tools';
import * as d3 from 'd3';
import React from 'react';
......@@ -14,7 +15,13 @@ interface Props extends React.SVGProps<SVGPathElement> {
const ChartArea = ({ xScale, yScale, color, data, disableAnimation, ...props }: Props) => {
const ref = React.useRef(null);
const theme = useTheme();
const gradientStopColor = useToken('colors', useColorModeValue('whiteAlpha.200', 'blackAlpha.100'));
const defaultGradient = {
startColor: useToken('colors', useColorModeValue('blue.100', 'blue.400')),
stopColor: useToken('colors', transparentize(useColorModeValue('blue.100', 'blue.400'), 0)(theme)),
};
React.useEffect(() => {
if (disableAnimation) {
......@@ -38,12 +45,19 @@ const ChartArea = ({ xScale, yScale, color, data, disableAnimation, ...props }:
return (
<>
<path ref={ ref } d={ d } fill={ color ? `url(#gradient-${ color })` : 'none' } opacity={ 0 } { ...props }/>
{ color && (
<path ref={ ref } d={ d } fill={ color ? `url(#gradient-${ color })` : 'url(#gradient-chart-area-default)' } opacity={ 0 } { ...props }/>
{ color ? (
<defs>
<linearGradient id={ `gradient-${ color }` } x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="2%" stopColor={ color }/>
<stop offset="78%" stopColor={ gradientStopColor }/>
<stop offset="20%" stopColor={ color }/>
<stop offset="100%" stopColor={ gradientStopColor }/>
</linearGradient>
</defs>
) : (
<defs>
<linearGradient id="gradient-chart-area-default" x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stopColor={ defaultGradient.startColor }/>
<stop offset="100%" stopColor={ defaultGradient.stopColor }/>
</linearGradient>
</defs>
) }
......
import React from 'react';
export const BlueLineGradient = {
id: 'blue-linear-gradient',
id: 'blue-line-gradient',
defs: () => (
<linearGradient id="blue-linear-gradient">
<linearGradient id="blue-line-gradient">
<stop offset="0%" stopColor="#4299E1"/>
<stop offset="100%" stopColor="#00B5D8"/>
</linearGradient>
......
......@@ -10,7 +10,12 @@ import SearchBar from 'ui/snippets/searchBar/SearchBar';
import Burger from './Burger';
import ColorModeToggler from './ColorModeToggler';
const Header = ({ hideOnScrollDown }: {hideOnScrollDown?: boolean}) => {
type Props = {
hasSearch: boolean;
hideOnScrollDown?: boolean;
}
const Header = ({ hideOnScrollDown, hasSearch }: Props) => {
const bgColor = useColorModeValue('white', 'black');
return (
......@@ -38,7 +43,7 @@ const Header = ({ hideOnScrollDown }: {hideOnScrollDown?: boolean}) => {
<NetworkLogo/>
<ProfileMenuMobile/>
</Flex>
<SearchBar withShadow={ !hideOnScrollDown }/>
{ hasSearch && <SearchBar withShadow={ !hideOnScrollDown }/> }
</Box><HStack
as="header"
width="100%"
......@@ -50,7 +55,7 @@ const Header = ({ hideOnScrollDown }: {hideOnScrollDown?: boolean}) => {
paddingTop={ 9 }
paddingBottom="52px"
>
<SearchBar/>
<Box width="100%">{ hasSearch && <SearchBar/> }</Box>
<ColorModeToggler/>
<ProfileMenuDesktop/>
</HStack>
......
......@@ -5,8 +5,14 @@ import link from 'lib/link/link';
import SearchBarDesktop from './SearchBarDesktop';
import SearchBarMobile from './SearchBarMobile';
import SearchBarMobileHome from './SearchBarMobileHome';
const SearchBar = ({ withShadow }: {withShadow?: boolean}) => {
type Props = {
withShadow?: boolean;
isHomepage?: boolean;
}
const SearchBar = ({ isHomepage, withShadow }: Props) => {
const [ value, setValue ] = React.useState('');
const handleChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
......@@ -21,8 +27,20 @@ const SearchBar = ({ withShadow }: {withShadow?: boolean}) => {
return (
<>
<SearchBarDesktop onChange={ handleChange } onSubmit={ handleSubmit }/>
<SearchBarMobile onChange={ handleChange } onSubmit={ handleSubmit } withShadow={ withShadow }/>
<SearchBarDesktop onChange={ handleChange } onSubmit={ handleSubmit } isHomepage={ isHomepage }/>
{ !isHomepage && (
<SearchBarMobile
onChange={ handleChange }
onSubmit={ handleSubmit }
withShadow={ withShadow }
/>
) }
{ isHomepage && (
<SearchBarMobileHome
onChange={ handleChange }
onSubmit={ handleSubmit }
/>
) }
</>
);
};
......
......@@ -7,11 +7,19 @@ import searchIcon from 'icons/search.svg';
interface Props {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
isHomepage?: boolean;
}
const SearchBarDesktop = ({ onChange, onSubmit }: Props) => {
const SearchBarDesktop = ({ onChange, onSubmit, isHomepage }: Props) => {
return (
<chakra.form noValidate onSubmit={ onSubmit } display={{ base: 'none', lg: 'block' }} w="100%">
<chakra.form
noValidate
onSubmit={ onSubmit }
display={{ base: 'none', lg: 'block' }}
w="100%"
backgroundColor={ isHomepage ? 'white' : 'none' }
borderRadius="10px"
>
<InputGroup>
<InputLeftAddon w="111px">All filters</InputLeftAddon>
<InputLeftElement w={ 6 } ml="132px" mr={ 2.5 }>
......@@ -22,7 +30,9 @@ const SearchBarDesktop = ({ onChange, onSubmit }: Props) => {
placeholder="Search by addresses / transactions / block / token... "
ml="1px"
onChange={ onChange }
border={ isHomepage ? 'none' : '2px solid' }
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
color={ useColorModeValue('black', 'white') }
/>
</InputGroup>
</chakra.form>
......
import { InputGroup, Input, InputLeftElement, Icon, LightMode, chakra } from '@chakra-ui/react';
import React from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import searchIcon from 'icons/search.svg';
interface Props {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
backgroundColor?: string;
}
const SearchBarMobileHome = ({ onChange, onSubmit }: Props) => {
const commonProps = {
noValidate: true,
onSubmit: onSubmit,
width: '100%',
display: { base: 'block', lg: 'none' },
};
return (
<LightMode>
<chakra.form
{ ...commonProps }
bgColor="white"
h="60px"
borderRadius="10px"
>
<InputGroup size="md">
<InputLeftElement >
<Icon as={ searchIcon } boxSize={ 6 } color="blackAlpha.600"/>
</InputLeftElement>
<Input
paddingInlineStart="38px"
placeholder="Search by addresses / ... "
ml="1px"
onChange={ onChange }
border="none"
color="black"
/>
</InputGroup>
</chakra.form>
</LightMode>
);
};
export default SearchBarMobileHome;
......@@ -4,14 +4,18 @@ import React from 'react';
import type { TransactionType } from 'types/api/transaction';
export interface Props {
type: TransactionType;
types: Array<TransactionType>;
}
const TxStatus = ({ type }: Props) => {
const TYPES_ORDER = [ 'token_creation', 'contract_creation', 'token_transfer', 'contract_call', 'coin_transfer' ];
const TxType = ({ types }: Props) => {
const typeToShow = types.sort((t1, t2) => TYPES_ORDER.indexOf(t1) - TYPES_ORDER.indexOf(t2))[0];
let label;
let colorScheme;
switch (type) {
switch (typeToShow) {
case 'contract_call':
label = 'Contract call';
colorScheme = 'blue';
......@@ -41,4 +45,4 @@ const TxStatus = ({ type }: Props) => {
);
};
export default TxStatus;
export default TxType;
......@@ -40,7 +40,7 @@ const TxsListItem = ({ tx, showBlockInfo }: {tx: Transaction; showBlockInfo: boo
<Box width="100%" borderBottom="1px solid" borderColor={ borderColor } _first={{ borderTop: '1px solid', borderColor }}>
<Flex justifyContent="space-between" mt={ 4 }>
<HStack>
{ tx.tx_types.map(item => <TxType key={ item } type={ item }/>) }
<TxType types={ tx.tx_types }/>
<TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined }/>
</HStack>
<AdditionalInfoButton onClick={ onOpen }/>
......
import { Alert, Spinner, Text, Link, chakra } from '@chakra-ui/react';
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import React from 'react';
import { ROUTES } from 'lib/link/routes';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
interface InjectedProps {
content: React.ReactNode;
......@@ -16,70 +12,13 @@ interface Props {
className?: string;
}
function getSocketParams(router: NextRouter) {
if (router.pathname === ROUTES.txs.pattern && router.query.tab === 'validated' && !router.query.block_number) {
return { topic: 'transactions:new_transaction' as const, event: 'transaction' as const };
}
if (router.pathname === ROUTES.txs.pattern && router.query.tab === 'pending' && !router.query.block_number) {
return { topic: 'transactions:new_pending_transaction' as const, event: 'pending_transaction' as const };
}
return {};
}
function assertIsNewTxResponse(response: unknown): response is { transaction: number } {
return typeof response === 'object' && response !== null && 'transaction' in response;
}
function assertIsNewPendingTxResponse(response: unknown): response is { pending_transaction: number } {
return typeof response === 'object' && response !== null && 'pending_transaction' in response;
}
const TxsNewItemNotice = ({ children, className }: Props) => {
const router = useRouter();
const [ num, setNum ] = React.useState(0);
const [ socketAlert, setSocketAlert ] = React.useState('');
const { topic, event } = getSocketParams(router);
const { num, socketAlert } = useNewTxsSocket();
const handleClick = React.useCallback(() => {
window.location.reload();
}, []);
const handleNewTxMessage = React.useCallback((response: { transaction: number } | { pending_transaction: number } | unknown) => {
if (assertIsNewTxResponse(response)) {
setNum((prev) => prev + response.transaction);
}
if (assertIsNewPendingTxResponse(response)) {
setNum((prev) => prev + response.pending_transaction);
}
}, []);
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please click here to load new transactions.');
}, []);
const handleSocketError = React.useCallback(() => {
setSocketAlert('An error has occurred while fetching new transactions. Please click here to refresh the page.');
}, []);
const channel = useSocketChannel({
topic,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: !topic,
});
useSocketMessage({
channel,
event,
handler: handleNewTxMessage,
});
if (!topic && !event) {
return null;
}
const content = (() => {
if (socketAlert) {
return (
......
......@@ -76,7 +76,7 @@ const TxsTableItem = ({ tx, showBlockInfo }: {tx: Transaction; showBlockInfo: bo
</Td>
<Td>
<VStack alignItems="start">
{ tx.tx_types.map(item => <TxType key={ item } type={ item }/>) }
<TxType types={ tx.tx_types }/>
<TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined }/>
</VStack>
</Td>
......
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