From eeb658ce567b5e50c7e4a316dbf7bf5db16f902a Mon Sep 17 00:00:00 2001 From: selankon Date: Tue, 20 Aug 2024 03:34:50 -0500 Subject: [PATCH] Implement new stats/home page (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ADd stats initial data * Create icons map * Improve stats cards * Refactor blockCard to accept compact * Pass grid props * Refactor stats card * Reuse component * WIP Implement ChainCosts * Use IncrementalStat * Implement details grid info * Implement show raw modal * Create hint component * Fix DetailsGrid layout * Implement ChainCosts * Minor layout fixes * Mock txCosts * Minor layout fixes * Create wide layout * Fix wide layout max width * Fix dancing words * Change read more use an icon instead * Fix image height * Refactor to use right component * Add close button to raw modal * Refactor price factors * Revert "Create wide layout" This reverts commit 28fddc8b4fdbc3d7ef6a3408ed4c1ded79a7fe6b. * Refactor chain info card * Fix prettier * Change homepage layout * Add blockTimestamp * Add blockTimestamp tooltip * Multiple string fixes * Show last 3 blocks * Fix prettier * Update extended-sdk and remove mock * Upgrade sdk commit * Fix missing import * Add error handling It also refactors the `StatisticsCardWrapper` into a `StatsCardWrapper` component. * Delete unused component * Fix in sync badge color * Use Raw IconButton * Implement StatsModalWrapper This way the price factors and VOC tokens price are shown inside two separated modals that can be opened from the Blockhain info card * Minor fixes - Delete commented - Comment useEffect --------- Co-authored-by: Òscar Casajuana --- src/components/Blocks/BlockCard.tsx | 48 +++-- src/components/Home/FeaturedContent.tsx | 4 +- src/components/Layout/ContentError.tsx | 4 +- src/components/Layout/DetailsGrid.tsx | 39 +++-- src/components/Layout/Hint.tsx | 56 ++++++ src/components/Layout/ShowRawButton.tsx | 52 +++++- .../Stats/ChainDetails/ChainInfo.tsx | 106 +++++++++++ .../Stats/ChainDetails/PriceFactors.tsx | 160 +++++++++++++++++ .../Stats/ChainDetails/StatsCards.tsx | 165 ++++++++++++++++++ src/components/Stats/ChainDetails/TxCosts.tsx | 45 +++++ src/components/Stats/ChainInfo.tsx | 57 ------ src/components/Stats/LatestBlocks.tsx | 18 +- src/components/Stats/StatsCardWrapper.tsx | 146 ++++++++++++++++ src/components/Stats/index.tsx | 100 ++--------- src/components/Validators/Detail.tsx | 13 +- src/components/Validators/ValidatorCard.tsx | 4 +- src/pages/Home.tsx | 11 +- src/pages/validator.tsx | 6 +- src/queries/stats.ts | 24 ++- src/router/index.tsx | 1 + src/theme/components/Icons.ts | 18 ++ yarn.lock | 33 ++-- 22 files changed, 886 insertions(+), 224 deletions(-) create mode 100644 src/components/Layout/Hint.tsx create mode 100644 src/components/Stats/ChainDetails/ChainInfo.tsx create mode 100644 src/components/Stats/ChainDetails/PriceFactors.tsx create mode 100644 src/components/Stats/ChainDetails/StatsCards.tsx create mode 100644 src/components/Stats/ChainDetails/TxCosts.tsx delete mode 100644 src/components/Stats/ChainInfo.tsx create mode 100644 src/components/Stats/StatsCardWrapper.tsx create mode 100644 src/theme/components/Icons.ts diff --git a/src/components/Blocks/BlockCard.tsx b/src/components/Blocks/BlockCard.tsx index 55ef2adb..54ac28c7 100644 --- a/src/components/Blocks/BlockCard.tsx +++ b/src/components/Blocks/BlockCard.tsx @@ -1,39 +1,31 @@ -import { Box, Card, CardBody, Flex, HStack, Icon, Text } from '@chakra-ui/react' +import { Box, Card, CardBody, Flex, Link, HStack, Icon, Text } from '@chakra-ui/react' import { BlockError, BlockNotFoundError } from '@vocdoni/extended-sdk' import { IChainBlockInfoResponse } from '@vocdoni/sdk' import { Trans, useTranslation } from 'react-i18next' import { BiTransferAlt } from 'react-icons/bi' -import { generatePath } from 'react-router-dom' + +import { generatePath, Link as RouterLink } from 'react-router-dom' import { ReducedTextAndCopy } from '~components/Layout/CopyButton' import LinkCard from '~components/Layout/LinkCard' import { RoutePath } from '~constants' import { useDateFns } from '~i18n/use-date-fns' +import { Icons } from '~src/theme/components/Icons' -export const BlockCard = ({ block }: { block: IChainBlockInfoResponse | BlockError }) => { - if (block instanceof BlockError) return - return ( - - ) +interface IBlockCardProps { + block: IChainBlockInfoResponse | BlockError + compact?: boolean } -const BlockInfoCard = ({ - height, - time, - proposer, - txn, -}: { - height: number - time: string - proposer: string - txn: number -}) => { - const date = new Date(time) +export const BlockCard = ({ block, compact = false }: IBlockCardProps) => { const { formatDistance } = useDateFns() + if (block instanceof BlockError) return + + const height = block.header.height + const time = block.header.time + const proposer = block.header.proposerAddress + const txn = block.data.txs.length + + const date = new Date(time) return ( @@ -42,7 +34,7 @@ const BlockInfoCard = ({ # {height} - + {txn} @@ -54,7 +46,11 @@ const BlockInfoCard = ({ Proposer: - + {proposer} diff --git a/src/components/Home/FeaturedContent.tsx b/src/components/Home/FeaturedContent.tsx index 23641d00..0a2d31d4 100644 --- a/src/components/Home/FeaturedContent.tsx +++ b/src/components/Home/FeaturedContent.tsx @@ -14,7 +14,7 @@ export const FeaturedContent = () => { const icons = [ { - width: '96px', + width: '72px', src: anonymous, alt: t('featured.anonymous_image_alt'), }, @@ -50,7 +50,7 @@ export const FeaturedContent = () => { {icons.map((icon, i) => ( - {icon.alt} + {icon.alt} ))} diff --git a/src/components/Layout/ContentError.tsx b/src/components/Layout/ContentError.tsx index 48c0d081..c2a396a0 100644 --- a/src/components/Layout/ContentError.tsx +++ b/src/components/Layout/ContentError.tsx @@ -6,7 +6,9 @@ export const NoResultsError = ({ msg }: { msg?: string }) => { return {msg ?? t('errors.no_results', { defaultValue: 'Looks like there are no results to show.' })} } -export const ContentError = ({ error }: { error: Error | undefined | null | string }) => { +export type ContentErrorType = Error | undefined | null | string + +export const ContentError = ({ error }: { error: ContentErrorType }) => { return ( diff --git a/src/components/Layout/DetailsGrid.tsx b/src/components/Layout/DetailsGrid.tsx index 3a1e5f35..975aff31 100644 --- a/src/components/Layout/DetailsGrid.tsx +++ b/src/components/Layout/DetailsGrid.tsx @@ -1,18 +1,19 @@ -import { Grid, GridItem, Text } from '@chakra-ui/react' +import { Flex, Grid, GridItem, GridProps, Text } from '@chakra-ui/react' import { PropsWithChildren } from 'react' +import Hint from '~components/Layout/Hint' -export type GridItemProps = { label: string } & PropsWithChildren +export type GridItemProps = { label: string; info?: string; isNumber?: boolean } & PropsWithChildren /** * Util component used to render a grid of details with its label * @param details String label, and the component that should render on the grid * @constructor */ -export const DetailsGrid = ({ details }: { details: GridItemProps[] }) => { +export const DetailsGrid = ({ details, ...rest }: { details: GridItemProps[] } & GridProps) => { return ( - - {details.map(({ label, children }, key) => ( - + + {details.map(({ children, ...rest }, key) => ( + {children} ))} @@ -20,14 +21,30 @@ export const DetailsGrid = ({ details }: { details: GridItemProps[] }) => { ) } -const DetailRow = ({ label, children }: GridItemProps) => { - const gridProps = { display: 'flex', alignItems: 'center' } +const DetailRow = ({ label, info, isNumber, children }: GridItemProps) => { return ( <> - - {label} + + + {info && } + + {label} + + + + + {children} - {children} ) } diff --git a/src/components/Layout/Hint.tsx b/src/components/Layout/Hint.tsx new file mode 100644 index 00000000..376a4f2f --- /dev/null +++ b/src/components/Layout/Hint.tsx @@ -0,0 +1,56 @@ +// Based from https://github.com/blockscout/frontend/blob/main/ui/shared/Hint.tsx +import type { TooltipProps } from '@chakra-ui/react' +import { chakra, IconButton, Skeleton, Tooltip, useDisclosure } from '@chakra-ui/react' +import React from 'react' +import { GrStatusInfo } from 'react-icons/gr' + +interface Props { + label: string | React.ReactNode + className?: string + tooltipProps?: Partial + isLoading?: boolean +} + +const Hint = ({ label, className, tooltipProps, isLoading }: Props) => { + // have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107 + const { isOpen, onOpen, onToggle, onClose } = useDisclosure() + + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + onToggle() + }, + [onToggle] + ) + + if (isLoading) { + return + } + + return ( + + } + boxSize={5} + variant='simple' + display='inline-block' + flexShrink={0} + className={className} + onMouseEnter={onOpen} + onMouseLeave={onClose} + onClick={handleClick} + /> + + ) +} + +export default React.memo(chakra(Hint)) diff --git a/src/components/Layout/ShowRawButton.tsx b/src/components/Layout/ShowRawButton.tsx index 6e7339dc..db1f2fe6 100644 --- a/src/components/Layout/ShowRawButton.tsx +++ b/src/components/Layout/ShowRawButton.tsx @@ -1,7 +1,23 @@ -import { Box, BoxProps, Button, ButtonProps, useDisclosure } from '@chakra-ui/react' +import { + Box, + BoxProps, + Button, + ButtonProps, + IconButton, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + useDisclosure, +} from '@chakra-ui/react' import { Trans } from 'react-i18next' import { CopyButtonIcon } from '~components/Layout/CopyButton' import { JsonViewer } from '~components/Layout/JsonViewer' +import { useTranslation } from 'react-i18next' +import { FaCode } from 'react-icons/fa' const ShowRawButton = ({ obj, ...props }: { obj: object } & Omit) => { const { getDisclosureProps, getButtonProps } = useDisclosure() @@ -27,4 +43,38 @@ export const RawContentBox = ({ obj, ...rest }: { obj: object } & BoxProps) => ( ) +export const RawModal = ({ obj, ...rest }: { obj: object } & Omit) => { + const { isOpen, onOpen, onClose } = useDisclosure() + const { t } = useTranslation() + return ( + <> + } + h={0} + p={0} + justifyContent={'end'} + aria-label={t('raw')} + size={'lg'} + variant={'text'} + /> + + + + + + + + + + + + + + + + + ) +} + export default ShowRawButton diff --git a/src/components/Stats/ChainDetails/ChainInfo.tsx b/src/components/Stats/ChainDetails/ChainInfo.tsx new file mode 100644 index 00000000..e8a7ea73 --- /dev/null +++ b/src/components/Stats/ChainDetails/ChainInfo.tsx @@ -0,0 +1,106 @@ +import { Badge, HStack, Text, VStack } from '@chakra-ui/react' +import { Trans, useTranslation } from 'react-i18next' +import { useChainInfo } from '~queries/stats' +import { useDateFns } from '~i18n/use-date-fns' +import { MdSpeed } from 'react-icons/md' +import { DetailsGrid, GridItemProps } from '~components/Layout/DetailsGrid' +import { StatsCardWrapper } from '~components/Stats/StatsCardWrapper' +import { TxCostsModal } from '~components/Stats/ChainDetails/TxCosts' +import { PriceFactorsModal } from '~components/Stats/ChainDetails/PriceFactors' + +const SyncBadge = ({ syncing }: { syncing: boolean }) => { + const { t } = useTranslation() + + const label = syncing ? t('stats.syncing') : t('stats.in_sync') + const color = syncing ? 'orange' : 'green' + + return {label} +} + +export const ChainInfo = () => { + const { t } = useTranslation() + const { data: stats, isError, error } = useChainInfo() + const { format } = useDateFns() + + if (!stats) return null + + const genesisBlockDate = format(new Date(stats.genesisTime), 'PPPpp') + const timestampInfo = format(new Date(stats?.blockTimestamp * 1000), 'hh:mm:ss') + + const statsData: GridItemProps[] = [ + { + label: t('stats.maxCensusSize', { defaultValue: 'Max Census Size' }), + children: stats.maxCensusSize, + isNumber: true, + }, + { + label: t('stats.networkCapacity', { defaultValue: 'Capacity (votes/block)' }), + // Not typed on the SDK + // @ts-ignore + children: stats.networkCapacity, + isNumber: true, + }, + { + label: t('stats.initial_height', { defaultValue: 'Epoch initial Height' }), + // Not typed on the SDK + // @ts-ignore + children: stats.initialHeight, + isNumber: true, + }, + { + label: t('stats.blockTimestamp', { defaultValue: 'Block timestamp' }), + children: timestampInfo, + isNumber: true, + }, + { + label: t('stats.blockTimestamp', { defaultValue: 'Block timestamp' }), + children: timestampInfo, + isNumber: true, + }, + ] + + const tokensData: GridItemProps[] = [ + { + label: t('stats.voc_tokens', { defaultValue: 'VOC Tokens' }), + children: , + isNumber: true, + }, + { + label: t('stats.price_factors', { defaultValue: 'Price factors' }), + children: , + isNumber: true, + }, + ] + + return ( + + + + + {stats.chainId} + + + + + {t('stats.genesis', { + date: genesisBlockDate, + defaultValue: 'Chain Genesis Epoch from {{date}}', + })} + + + + + Tokens + + + + ) +} diff --git a/src/components/Stats/ChainDetails/PriceFactors.tsx b/src/components/Stats/ChainDetails/PriceFactors.tsx new file mode 100644 index 00000000..56bc7131 --- /dev/null +++ b/src/components/Stats/ChainDetails/PriceFactors.tsx @@ -0,0 +1,160 @@ +import { useChainCosts } from '~queries/stats' +import { useTranslation } from 'react-i18next' +import { IoIosPricetag } from 'react-icons/io' +import { + Box, + Button, + HStack, + Icon, + IconButton, + Link, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + useDisclosure, + VStack, +} from '@chakra-ui/react' +import { DetailsGrid } from '~components/Layout/DetailsGrid' +import { Icons } from '~src/theme/components/Icons' +import { ContentError } from '~components/Layout/ContentError' +import { RawModal } from '~components/Layout/ShowRawButton' +import { StatsModalWrapper } from '~components/Stats/StatsCardWrapper' + +export const PriceFactorsModal = () => { + const { t } = useTranslation() + const { data, isError, error, isLoading } = useChainCosts({}) + + return ( + <> + + + + + ) +} + +const PriceFactorsInfoSkeleton = () => { + const { data, isError, error } = useChainCosts({}) + const { t } = useTranslation() + + if (isError) { + return + } + + return ( + + + {t('stats.price_factors.info_text', { + defaultValue: + 'The formula used to calculate an election price takes into account various factors, each reflecting one specific aspect of the election process. ', + })} + + + {t('stats.price_factors.factors', { defaultValue: 'Factors' })} + + + ) +} diff --git a/src/components/Stats/ChainDetails/StatsCards.tsx b/src/components/Stats/ChainDetails/StatsCards.tsx new file mode 100644 index 00000000..b688277a --- /dev/null +++ b/src/components/Stats/ChainDetails/StatsCards.tsx @@ -0,0 +1,165 @@ +import { IconType } from 'react-icons' +import { Box, Card, CardBody, Flex, Grid, Heading, Icon, Link, Stack, Text } from '@chakra-ui/react' +import { generatePath, Link as RouterLink } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { useChainInfo } from '~queries/stats' +import { RefreshIntervalBlocks, RoutePath } from '~constants' +import { useTranslation } from 'react-i18next' +import { Icons } from '~src/theme/components/Icons' +import { ContentError } from '~components/Layout/ContentError' + +interface IStatsCardProps { + title: string + description: string + link?: string + icon: IconType +} + +const StatsCard = ({ title, description, link, icon }: IStatsCardProps) => ( + + + + + + + + {title} + + {description} + + + + + +) + +interface IncrementalStatProps { + value: number + label: string +} + +const IncrementalStat = ({ value, label }: IncrementalStatProps) => { + const [displayNumber, setDisplayNumber] = useState(0) + const duration = 2000 + + // Incremental animation to create a counter effect + useEffect(() => { + let intervalId: NodeJS.Timeout + let start = Date.now() + const stepTime = duration / value + + intervalId = setInterval(() => { + const elapsed = Date.now() - start + const remainingTime = duration - elapsed + const randomOffset = Math.floor(Math.random() * 10) // Random offset between 0 and 9 + const nextNumber = Math.min(value, Math.floor(elapsed / stepTime) + randomOffset) + + // Ensure the final number is the target number + if (remainingTime <= stepTime) { + setDisplayNumber(value) + clearInterval(intervalId) + } else { + setDisplayNumber(nextNumber) + } + }, stepTime) + + return () => clearInterval(intervalId) + }, [value, duration]) + + return ( + + + {displayNumber} + + {label} + + ) +} + +export const StatsCards = () => { + const { + data: stats, + isError, + error, + } = useChainInfo({ + refetchInterval: RefreshIntervalBlocks, + }) + const { t } = useTranslation() + + if (!stats) return null + + if (isError) { + return + } + + const averageBlockTime = Number((stats?.blockTime[0] || 0) / 1000).toFixed(1) + + const statsCards: IStatsCardProps[] = [ + { + title: t('stats.average_block_time'), + description: t('stats.seconds', { + count: parseFloat(averageBlockTime), + }), + link: RoutePath.BlocksList, + icon: Icons.ClockIcon, + }, + { + title: t('stats.block_height'), + description: t('stats.blocks', { + count: stats.height, + defaultValue: '{{count}} blocks', + }), + link: RoutePath.BlocksList, + icon: Icons.BlockIcon, + }, + { + title: t('stats.transactions_count', { defaultValue: 'Transactions count' }), + description: t('stats.transactions', { + count: stats.transactionCount, + defaultValue: '{{count}} transactions', + }), + link: RoutePath.TransactionsList, + icon: Icons.TxIcon, + }, + { + title: t('stats.validators_count', { defaultValue: 'Validators count' }), + description: t('stats.validators', { + count: stats.validatorCount, + defaultValue: '{{count}} validators', + }), + link: RoutePath.Validators, + icon: Icons.ValidatorIcon, + }, + ] + + const incrementalStats: IncrementalStatProps[] = [ + { + label: t('stats.organizations', { defaultValue: 'Organizations' }), + value: stats.organizationCount, + }, + { + label: t('stats.elections', { defaultValue: 'Elections' }), + value: stats.electionCount, + }, + { + label: t('stats.votes', { defaultValue: 'Votes' }), + value: stats.voteCount, + }, + ] + + return ( + + + {statsCards.map((card, i) => ( + + ))} + + + + {incrementalStats.map((card, i) => ( + + ))} + + + ) +} diff --git a/src/components/Stats/ChainDetails/TxCosts.tsx b/src/components/Stats/ChainDetails/TxCosts.tsx new file mode 100644 index 00000000..35fb27f1 --- /dev/null +++ b/src/components/Stats/ChainDetails/TxCosts.tsx @@ -0,0 +1,45 @@ +import { useChainCosts, useTxsCosts } from '~queries/stats' +import { useTranslation } from 'react-i18next' +import { Icons } from '~src/theme/components/Icons' +import { DetailsGrid, GridItemProps } from '~components/Layout/DetailsGrid' +import { StatsModalWrapper } from '~components/Stats/StatsCardWrapper' + +export const TxCostsModal = () => { + const { t } = useTranslation() + const { data, isLoading, isError, error } = useTxsCosts({}) + + return ( + <> + + + + + ) +} + +const TxCosts = () => { + const { data } = useTxsCosts({}) + + const { t } = useTranslation() + + const prices: GridItemProps[] = [] + + if (data?.costs) { + Object.entries(data.costs).forEach(([key, value]) => { + prices.push({ + label: key, + children: value, + isNumber: true, + }) + }) + } + + return +} diff --git a/src/components/Stats/ChainInfo.tsx b/src/components/Stats/ChainInfo.tsx deleted file mode 100644 index c71c9ab7..00000000 --- a/src/components/Stats/ChainInfo.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Flex, Heading, SkeletonText, Text } from '@chakra-ui/react' -import { useTranslation } from 'react-i18next' -import { useChainInfo } from '~queries/stats' -import { useDateFns } from '~i18n/use-date-fns' - -export const ChainInfo = () => { - const { t } = useTranslation() - const { data: stats } = useChainInfo() - const { formatDistance } = useDateFns() - - const syncing = stats?.syncing ? t('stats.syncing') : t('stats.in_sync') - const genesisBlockDate = stats?.genesisTime ? formatDistance(new Date(stats?.genesisTime), new Date()) : '' - - const statsCards = [ - { - name: t('stats.network_id'), - data: stats?.chainId, - }, - { - name: t('stats.block_height'), - data: stats?.height, - }, - - { - name: t('stats.nr_of_validators'), - data: stats?.validatorCount, - }, - { - name: t('stats.sync_status'), - data: syncing, - }, - - { - name: t('stats.genesis_block_date'), - data: genesisBlockDate, - }, - ] - - return ( - - {statsCards.map((stat, i) => { - return ( - - {stat.name} - {!stats ? ( - - ) : ( - - {stat.data} - - )} - - ) - })} - - ) -} diff --git a/src/components/Stats/LatestBlocks.tsx b/src/components/Stats/LatestBlocks.tsx index 8d6007fe..7fee8aa4 100644 --- a/src/components/Stats/LatestBlocks.tsx +++ b/src/components/Stats/LatestBlocks.tsx @@ -7,12 +7,18 @@ import { LoadingCards } from '~components/Layout/Loading' import { RoutePath } from '~constants' import { useBlockList } from '~queries/blocks' import { useChainInfo } from '~queries/stats' +import { ContentError } from '~components/Layout/ContentError' export const LatestBlocks = () => { - const blockListSize = 4 + const blockListSize = 3 const { data: stats, isLoading: isLoadingStats } = useChainInfo() - const { data: blocks, isLoading: isLoadingBlocks } = useBlockList({ + const { + data: blocks, + isLoading: isLoadingBlocks, + error, + isError, + } = useBlockList({ params: { page: 0, limit: blockListSize, @@ -28,10 +34,14 @@ export const LatestBlocks = () => { return } + if (isError) { + return + } + return ( - + {blocks.blocks.map((block, i) => ( - + ))} + + + + + ) +} + +const CardBodyWrapper = ({ + isLoading, + isError, + error, + children, +}: Pick & PropsWithChildren) => { + if (isLoading) { + return + } + if (isError) { + return + } + return ( + + {children} + + ) +} diff --git a/src/components/Stats/index.tsx b/src/components/Stats/index.tsx index 0b9b7dc5..df5d6cad 100644 --- a/src/components/Stats/index.tsx +++ b/src/components/Stats/index.tsx @@ -1,101 +1,23 @@ -import { Card, CardBody, CardHeader, Flex, Grid, Icon, Text } from '@chakra-ui/react' -import { PropsWithChildren } from 'react' +import { Flex, VStack } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' -import { IconType } from 'react-icons' -import { MdSpeed } from 'react-icons/md' import { VscGraphLine } from 'react-icons/vsc' -import { generatePath, Link } from 'react-router-dom' -import { ChainInfo } from '~components/Stats/ChainInfo' import { LatestBlocks } from '~components/Stats/LatestBlocks' -import { RefreshIntervalBlocks, RoutePath } from '~constants' -import { useChainInfo } from '~queries/stats' - -interface IStatsCardProps { - title: string - description: string -} - -const StatsCard = ({ title, description }: IStatsCardProps) => ( - - - {title} - - - {description} - - -) - -interface StatisticsCardProps { - title: string - icon: IconType -} - -const StatisticsCardWrapper = ({ title, icon, children }: StatisticsCardProps & PropsWithChildren) => ( - - - - {title} - - {children} - -) +import { ChainInfo } from '~components/Stats/ChainDetails/ChainInfo' +import { StatsCards } from '~components/Stats/ChainDetails/StatsCards' +import { StatsCardWrapper } from '~components/Stats/StatsCardWrapper' const Stats = () => { - const { data: stats } = useChainInfo({ - refetchInterval: RefreshIntervalBlocks, - }) const { t } = useTranslation() - - const averageBlockTime = Number((stats?.blockTime[0] || 0) / 1000).toFixed(1) - - const statsCards = [ - { - title: t('stats.average_block_time'), - description: t('stats.seconds', { - count: parseFloat(averageBlockTime), - }), - link: RoutePath.BlocksList, - }, - { - title: t('stats.total_elections'), - description: t('stats.electionCount', { - count: stats?.electionCount, - }), - link: RoutePath.ProcessesList, - }, - { - title: t('stats.total_organizations'), - description: t('stats.organizations', { - count: stats?.organizationCount, - }), - link: RoutePath.OrganizationsList, - }, - { - title: t('stats.total_votes'), - description: t('stats.votes', { - count: stats?.voteCount, - }), - link: RoutePath.TransactionsList, - }, - ] + const cardSpacing = 4 return ( - - - {statsCards.map((card, i) => ( - - - - ))} - - - + + + + - - - - + + ) diff --git a/src/components/Validators/Detail.tsx b/src/components/Validators/Detail.tsx index 4dd39bfe..4ca7bad5 100644 --- a/src/components/Validators/Detail.tsx +++ b/src/components/Validators/Detail.tsx @@ -10,16 +10,7 @@ import { ValidatorName } from '~components/Validators/ValidatorCard' import { generatePath } from 'react-router-dom' import { RoutePath } from '~constants' -export type ValidatorFixedType = IChainValidator & { - // todo(kon): delete this type extension when https://github.com/vocdoni/vocdoni-sdk/pull/402 is merged - joinHeight: number - proposals: number - score: number - validatorAddress: string - votes: number -} - -const DetailsTab = ({ validator }: { validator: ValidatorFixedType }) => { +const DetailsTab = ({ validator }: { validator: IChainValidator }) => { const address = ensure0x(validator.address) const pubKey = ensure0x(validator.pubKey) @@ -80,7 +71,7 @@ const DetailsTab = ({ validator }: { validator: ValidatorFixedType }) => { ) } -export const ValidatorDetail = ({ validator }: { validator: ValidatorFixedType }) => { +export const ValidatorDetail = ({ validator }: { validator: IChainValidator }) => { return ( diff --git a/src/components/Validators/ValidatorCard.tsx b/src/components/Validators/ValidatorCard.tsx index 27b1e1d3..392a999e 100644 --- a/src/components/Validators/ValidatorCard.tsx +++ b/src/components/Validators/ValidatorCard.tsx @@ -3,8 +3,8 @@ import { Trans } from 'react-i18next' import { ReducedTextAndCopy } from '~components/Layout/CopyButton' import { generatePath } from 'react-router-dom' import { RoutePath } from '~constants' -import { ValidatorFixedType } from '~components/Validators/Detail' import LinkCard from '~components/Layout/LinkCard' +import { IChainValidator } from '@vocdoni/sdk' export const ValidatorName = ({ name, useCopy, address }: { name?: string; useCopy?: boolean; address: string }) => { const showName = !!name @@ -40,7 +40,7 @@ export const ValidatorName = ({ name, useCopy, address }: { name?: string; useCo ) } -export const ValidatorCard = (validator: ValidatorFixedType) => { +export const ValidatorCard = (validator: IChainValidator) => { return ( diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 7fd2c2fe..97956c68 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,5 +1,14 @@ import LandingPage from '~components/Home' +import { useLoaderData } from 'react-router-dom' +import { IChainGetInfoResponse } from '@vocdoni/sdk' +import { useChainInfo } from '~queries/stats' -const Home = () => +const Home = () => { + const chainInfo = useLoaderData() as IChainGetInfoResponse + const {} = useChainInfo({ + initialData: chainInfo, + }) + return +} export default Home diff --git a/src/pages/validator.tsx b/src/pages/validator.tsx index 5ac56c01..a98bc2fb 100644 --- a/src/pages/validator.tsx +++ b/src/pages/validator.tsx @@ -1,9 +1,9 @@ import { useLoaderData, useParams } from 'react-router-dom' -import { ensure0x, IChainValidatorsListResponse } from '@vocdoni/sdk' -import { ValidatorDetail, ValidatorFixedType } from '~components/Validators/Detail' +import { ensure0x, IChainValidator, IChainValidatorsListResponse } from '@vocdoni/sdk' +import { ValidatorDetail } from '~components/Validators/Detail' const Validator = () => { - const validators = (useLoaderData() as IChainValidatorsListResponse).validators as Array + const validators = (useLoaderData() as IChainValidatorsListResponse).validators as Array const { address }: { address?: string } = useParams() diff --git a/src/queries/stats.ts b/src/queries/stats.ts index ae2943c8..1847c421 100644 --- a/src/queries/stats.ts +++ b/src/queries/stats.ts @@ -1,7 +1,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { useClient } from '@vocdoni/react-providers' -import { ChainAPI, IChainGetInfoResponse } from '@vocdoni/sdk' import { ExtendedSDKClient } from '@vocdoni/extended-sdk' +import { useClient } from '@vocdoni/react-providers' +import { ChainAPI, IChainGetCostsResponse, IChainGetInfoResponse, IChainTxCosts } from '@vocdoni/sdk' export type useChainInfoOptions = Omit, 'queryKey'> @@ -9,7 +9,7 @@ export const useChainInfo = (options?: useChainInfoOptions) => { const { client } = useClient() return useQuery({ - queryKey: ['chainStats'], + queryKey: ['chainStats', 'stats'], queryFn: client.chainInfo, ...options, }) @@ -28,3 +28,21 @@ export const useBlockToDate = ({ ...options, }) } + +export const useChainCosts = ({ ...options }: Omit, 'queryKey'>) => { + const { client } = useClient() + return useQuery({ + queryKey: ['chainStats', 'costs'], + queryFn: () => client.fetchChainCosts(), + ...options, + }) +} + +export const useTxsCosts = ({ ...options }: Omit, 'queryKey'>) => { + const { client } = useClient() + return useQuery({ + queryKey: ['chainStats', 'txCost'], + queryFn: () => client.txCosts(), + ...options, + }) +} diff --git a/src/router/index.tsx b/src/router/index.tsx index dea6bb0e..d941b9c2 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -44,6 +44,7 @@ export const RoutesProvider = () => { ), + loader: async ({ params }) => await client.chainInfo(), }, { path: RoutePath.Block, diff --git a/src/theme/components/Icons.ts b/src/theme/components/Icons.ts new file mode 100644 index 00000000..0538d460 --- /dev/null +++ b/src/theme/components/Icons.ts @@ -0,0 +1,18 @@ +import { IconType } from 'react-icons' +import { BiTransferAlt } from 'react-icons/bi' +import { HiOutlineCube } from 'react-icons/hi2' +import { IoTimeOutline } from 'react-icons/io5' +import { GrValidate } from 'react-icons/gr' +import { FaExternalLinkAlt } from 'react-icons/fa' +import { FiInfo } from 'react-icons/fi' + +type IconName = 'BlockIcon' | 'TxIcon' | 'ClockIcon' | 'ValidatorIcon' | 'ExternalIcon' | 'InfoIcon' + +export const Icons: { [key in IconName]: IconType } = { + TxIcon: BiTransferAlt, + BlockIcon: HiOutlineCube, + ClockIcon: IoTimeOutline, + ValidatorIcon: GrValidate, + ExternalIcon: FaExternalLinkAlt, + InfoIcon: FiInfo, +} diff --git a/yarn.lock b/yarn.lock index 28a07840..25998dc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -178,13 +178,20 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.18.3", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.8": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.8": version "7.24.8" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.8.tgz#5d958c3827b13cc6d05e038c07fb2e5e3420d82e" integrity sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA== dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.15.4": + version "7.25.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb" + integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" @@ -1903,11 +1910,11 @@ integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== "@types/node@>=13.7.0": - version "20.14.12" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.12.tgz#129d7c3a822cb49fc7ff661235f19cfefd422b49" - integrity sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ== + version "22.4.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.4.1.tgz#9b595d292c65b94c20923159e2ce947731b6fdce" + integrity sha512-1tbpb9325+gPnKK0dMm+/LMriX0vKxf6RnB0SZUqfyVkQ4fMgUSySqhxE/y8Jvs4NyF1yHzTfG9KlnkIODxPKg== dependencies: - undici-types "~5.26.4" + undici-types "~6.19.2" "@types/parse-json@^4.0.0": version "4.0.2" @@ -1970,9 +1977,9 @@ "@vocdoni/react-providers" "~0.4.4" "@vocdoni/extended-sdk@^0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@vocdoni/extended-sdk/-/extended-sdk-0.1.3.tgz#390a77d9e434e33e0c6dd79fcb48e73db3bb3155" - integrity sha512-iMtlWybFdwqu0t458SdMS28q6A826Y7IBOK4UOKX+xmiemKu0Enl/o4YDBa6UVBMjNZ7LHuO60U1Jv3Rpt1gaA== + version "0.1.4" + resolved "https://registry.yarnpkg.com/@vocdoni/extended-sdk/-/extended-sdk-0.1.4.tgz#e411ea85d08477a3abe71a9bccf9f52428753d8a" + integrity sha512-Mu9YA8uywq+GTx8TFnnhp84R/5gfVdE/o6xLy+Om0iIgdCgOk/sAchFx9RUOH3BkDJsr7DkTLHm/AFPAVVL/xg== "@vocdoni/proto@1.15.8": version "1.15.8" @@ -1989,7 +1996,7 @@ "@vocdoni/sdk@https://github.com/vocdoni/vocdoni-sdk.git#main": version "0.8.3" - resolved "https://github.com/vocdoni/vocdoni-sdk.git#ef8eebceabb8466129f955c031c0cc00a8dcbb9b" + resolved "https://github.com/vocdoni/vocdoni-sdk.git#0f6707560b7c8d3f10888ef928ae6edaf9aeab57" dependencies: "@ethersproject/abstract-signer" "^5.7.0" "@ethersproject/address" "^5.7.0" @@ -4697,10 +4704,10 @@ underscore.string@~3.3.4: sprintf-js "^1.1.1" util-deprecate "^1.0.2" -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.2: + version "6.19.6" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.6.tgz#e218c3df0987f4c0e0008ca00d6b6472d9b89b36" + integrity sha512-e/vggGopEfTKSvj4ihnOLTsqhrKRN3LeO6qSN/GxohhuRv8qH9bNQ4B8W7e/vFL+0XTnmHPB4/kegunZGA4Org== unified@^10.0.0: version "10.1.2"