diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 1bf96da312..537b79b7d8 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -726,12 +726,34 @@ const schema = yup .array() .transform(replaceQuotes) .json() - .of(yup.string().oneOf(CHAIN_INDICATOR_IDS)), + .of(yup.string().oneOf(CHAIN_INDICATOR_IDS)) + .test( + 'stats-api-required', + 'NEXT_PUBLIC_STATS_API_HOST is required when daily_operational_txs is enabled in NEXT_PUBLIC_HOMEPAGE_CHARTS', + function(value) { + // daily_operational_txs is presented only in stats microservice + if (value?.includes('daily_operational_txs')) { + return Boolean(this.parent.NEXT_PUBLIC_STATS_API_HOST); + } + return true; + } + ), NEXT_PUBLIC_HOMEPAGE_STATS: yup .array() .transform(replaceQuotes) .json() - .of(yup.string().oneOf(HOME_STATS_WIDGET_IDS)), + .of(yup.string().oneOf(HOME_STATS_WIDGET_IDS)) + .test( + 'stats-api-required', + 'NEXT_PUBLIC_STATS_API_HOST is required when total_operational_txs is enabled in NEXT_PUBLIC_HOMEPAGE_STATS', + function(value) { + // total_operational_txs is presented only in stats microservice + if (value?.includes('total_operational_txs')) { + return Boolean(this.parent.NEXT_PUBLIC_STATS_API_HOST); + } + return true; + } + ), NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR: yup.string(), NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND: yup.string(), NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG: yup diff --git a/docs/ENVS.md b/docs/ENVS.md index 575cf38969..2a3f2d12c5 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -126,8 +126,8 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will | Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | --- | --- | --- | --- | --- | --- | --- | -| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'coin_price' \| 'secondary_coin_price' \| 'market_cap' \| 'tvl'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cap']` | v1.0.x+ | -| NEXT_PUBLIC_HOMEPAGE_STATS | `Array<'latest_batch' \| 'total_blocks' \| 'average_block_time' \| 'total_txs' \| 'latest_l1_state_batch' \| 'wallet_addresses' \| 'gas_tracker' \| 'btc_locked' \| 'current_epoch'>` | List of stats widgets displayed on the home page | - | For zkSync, zkEvm and Arbitrum rollups: `['latest_batch','average_block_time','total_txs','wallet_addresses','gas_tracker']`, for other cases: `['total_blocks','average_block_time','total_txs','wallet_addresses','gas_tracker']` | `['total_blocks','total_txs','wallet_addresses']` | v1.35.x+ | +| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'daily_operational_txs' \| 'coin_price' \| 'secondary_coin_price' \| 'market_cap' \| 'tvl'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cap']` | v1.0.x+ | +| NEXT_PUBLIC_HOMEPAGE_STATS | `Array<'latest_batch' \| 'total_blocks' \| 'average_block_time' \| 'total_txs' \| 'total_operational_txs' \| 'latest_l1_state_batch' \| 'wallet_addresses' \| 'gas_tracker' \| 'btc_locked' \| 'current_epoch'>` | List of stats widgets displayed on the home page | - | For zkSync, zkEvm and Arbitrum rollups: `['latest_batch','average_block_time','total_txs','wallet_addresses','gas_tracker']`, for other cases: `['total_blocks','average_block_time','total_txs','wallet_addresses','gas_tracker']` | `['total_blocks','total_txs','wallet_addresses']` | v1.35.x+ | | NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR | `string` | Text color of the hero plate on the homepage (escape "#" symbol if you use HEX color codes or use rgba-value instead). **DEPRECATED** _Use `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG` instead_ | - | `white` | `\#DCFE76` | v1.0.x+ | | NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND | `string` | Background css value for hero plate on the homepage (escape "#" symbol if you use HEX color codes or use rgba-value instead). **DEPRECATED** _Use `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG` instead_ | - | `radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)` | `radial-gradient(at 15% 86%, hsla(350,65%,70%,1) 0px, transparent 50%)` \| `no-repeat bottom 20% right 0px/100% url(https://placekitten/1400/200)` | v1.1.0+ | | NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG | `HeroBannerConfig`, see details [below](#hero-banner-configuration-properties) | Configuration of hero banner appearance. | - | - | See [below](#hero-banner-configuration-properties) | v1.35.0+ | diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 65bee48a44..43deb692fb 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -290,6 +290,21 @@ export const RESOURCES = { endpoint: getFeaturePayload(config.features.stats)?.api.endpoint, basePath: getFeaturePayload(config.features.stats)?.api.basePath, }, + stats_main: { + path: '/api/v1/pages/main', + endpoint: getFeaturePayload(config.features.stats)?.api.endpoint, + basePath: getFeaturePayload(config.features.stats)?.api.basePath, + }, + stats_transactions: { + path: '/api/v1/pages/transactions', + endpoint: getFeaturePayload(config.features.stats)?.api.endpoint, + basePath: getFeaturePayload(config.features.stats)?.api.basePath, + }, + stats_contracts: { + path: '/api/v1/pages/contracts', + endpoint: getFeaturePayload(config.features.stats)?.api.endpoint, + basePath: getFeaturePayload(config.features.stats)?.api.basePath, + }, // NAME SERVICE addresses_lookup: { @@ -1481,6 +1496,9 @@ Q extends 'advanced_filter' ? AdvancedFilterResponse : Q extends 'advanced_filter_methods' ? AdvancedFilterMethodsResponse : Q extends 'pools' ? PoolsResponse : Q extends 'pool' ? Pool : +Q extends 'stats_main' ? stats.MainPageStats : +Q extends 'stats_transactions' ? stats.TransactionsPageStats : +Q extends 'stats_contracts' ? stats.ContractsPageStats : never; /* eslint-enable @stylistic/indent */ diff --git a/mocks/stats/main.tsx b/mocks/stats/main.tsx new file mode 100644 index 0000000000..a3a6bbd016 --- /dev/null +++ b/mocks/stats/main.tsx @@ -0,0 +1,71 @@ +import type * as stats from '@blockscout/stats-types'; + +import { averageGasPrice } from './line'; + +export const base: stats.MainPageStats = { + average_block_time: { + id: 'averageBlockTime', + value: '14.909090909090908', + title: 'Average block time', + units: 's', + description: 'Average time taken in seconds for a block to be included in the blockchain', + }, + total_addresses: { + id: 'totalAddresses', + value: '113606435', + title: 'Total addresses', + description: 'Number of addresses that participated in the blockchain', + }, + total_blocks: { + id: 'totalBlocks', + value: '7660515', + title: 'Total blocks', + description: 'Number of blocks over all time', + }, + total_transactions: { + id: 'totalTxns', + value: '411264599', + title: 'Total txns', + description: 'All transactions including pending, dropped, replaced, failed transactions', + }, + yesterday_transactions: { + id: 'yesterdayTxns', + value: '213019', + title: 'Yesterday txns', + description: 'Number of transactions yesterday (0:00 - 23:59 UTC)', + }, + total_operational_transactions: { + id: 'totalOperationalTxns', + value: '403598877', + title: 'Total operational txns', + description: '\'Total txns\' without block creation transactions', + }, + yesterday_operational_transactions: { + id: 'yesterdayOperationalTxns', + value: '210852', + title: 'Yesterday operational txns', + description: 'Number of transactions yesterday (0:00 - 23:59 UTC) without block creation transactions', + }, + daily_new_transactions: { + chart: averageGasPrice.chart, + info: { + id: 'newTxnsWindow', + title: 'Daily transactions', + description: 'The chart displays daily transactions for the past 30 days', + resolutions: [ + 'DAY', + ], + }, + }, + daily_new_operational_transactions: { + chart: averageGasPrice.chart, + info: { + id: 'newOperationalTxnsWindow', + title: 'Daily operational transactions', + description: 'The chart displays daily transactions for the past 30 days (without block creation transactions)', + resolutions: [ + 'DAY', + ], + }, + }, +}; diff --git a/package.json b/package.json index 53140d644f..abf00bd5dc 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ }, "dependencies": { "@blockscout/bens-types": "1.4.1", - "@blockscout/stats-types": "2.0.0", + "@blockscout/stats-types": "2.5.0-alpha", "@blockscout/visualizer-types": "0.2.0", "@chakra-ui/react": "2.7.1", "@chakra-ui/theme-tools": "^2.0.18", diff --git a/stubs/contract.ts b/stubs/contract.ts index cb9111cfdb..2b73224252 100644 --- a/stubs/contract.ts +++ b/stubs/contract.ts @@ -1,9 +1,11 @@ +import type * as stats from '@blockscout/stats-types'; import type { SmartContract, SmartContractMudSystemsResponse } from 'types/api/contract'; import type { VerifiedContract, VerifiedContractsCounters } from 'types/api/contracts'; import type { SolidityScanReport } from 'lib/solidityScan/schema'; import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams'; +import { STATS_COUNTER } from './stats'; export const CONTRACT_CODE_UNVERIFIED = { creation_bytecode: '0x60806040526e', @@ -81,6 +83,13 @@ export const VERIFIED_CONTRACTS_COUNTERS: VerifiedContractsCounters = { new_verified_smart_contracts_24h: '1234', }; +export const VERIFIED_CONTRACTS_COUNTERS_MICROSERVICE: stats.ContractsPageStats = { + total_contracts: STATS_COUNTER, + new_contracts_24h: STATS_COUNTER, + total_verified_contracts: STATS_COUNTER, + new_verified_contracts_24h: STATS_COUNTER, +}; + export const SOLIDITY_SCAN_REPORT: SolidityScanReport = { scan_report: { contractname: 'BullRunners', diff --git a/stubs/stats.ts b/stubs/stats.ts index 3e7be4b565..976debc279 100644 --- a/stubs/stats.ts +++ b/stubs/stats.ts @@ -42,17 +42,19 @@ export const HOMEPAGE_STATS: HomeStats = { tvl: '1767425.102766552', }; +const STATS_CHART_INFO: stats.LineChartInfo = { + id: 'chart_0', + title: 'Average transaction fee', + description: 'The average amount in ETH spent per transaction', + units: 'ETH', + resolutions: [ 'DAY', 'MONTH' ], +}; + export const STATS_CHARTS_SECTION: stats.LineChartSection = { id: 'placeholder', title: 'Placeholder', charts: [ - { - id: 'chart_0', - title: 'Average transaction fee', - description: 'The average amount in ETH spent per transaction', - units: 'ETH', - resolutions: [ 'DAY', 'MONTH' ], - }, + STATS_CHART_INFO, { id: 'chart_1', title: 'Transactions fees', @@ -88,3 +90,21 @@ export const STATS_COUNTER: stats.Counter = { description: 'Placeholder description', units: '', }; + +export const HOMEPAGE_STATS_MICROSERVICE: stats.MainPageStats = { + average_block_time: STATS_COUNTER, + total_addresses: STATS_COUNTER, + total_blocks: STATS_COUNTER, + total_transactions: STATS_COUNTER, + yesterday_transactions: STATS_COUNTER, + total_operational_transactions: STATS_COUNTER, + yesterday_operational_transactions: STATS_COUNTER, + daily_new_transactions: { + chart: [], + info: STATS_CHART_INFO, + }, + daily_new_operational_transactions: { + chart: [], + info: STATS_CHART_INFO, + }, +}; diff --git a/stubs/tx.ts b/stubs/tx.ts index 211e73a026..a1c7f728e3 100644 --- a/stubs/tx.ts +++ b/stubs/tx.ts @@ -1,7 +1,9 @@ +import type * as stats from '@blockscout/stats-types'; import type { RawTracesResponse } from 'types/api/rawTrace'; import type { Transaction, TransactionsStats } from 'types/api/transaction'; import { ADDRESS_PARAMS } from './addressParams'; +import { STATS_COUNTER } from './stats'; export const TX_HASH = '0x3ed9d81e7c1001bdda1caa1dc62c0acbbe3d2c671cdc20dc1e65efdaa4186967'; @@ -66,3 +68,11 @@ export const TXS_STATS: TransactionsStats = { transaction_fees_sum_24h: '22184012506492688277', transactions_count_24h: '992890', }; + +export const TXS_STATS_MICROSERVICE: stats.TransactionsPageStats = { + pending_transactions_30m: STATS_COUNTER, + transactions_24h: STATS_COUNTER, + operational_transactions_24h: STATS_COUNTER, + transactions_fee_24h: STATS_COUNTER, + average_transactions_fee_24h: STATS_COUNTER, +}; diff --git a/types/homepage.ts b/types/homepage.ts index d8d06647c9..4213625dd9 100644 --- a/types/homepage.ts +++ b/types/homepage.ts @@ -1,4 +1,4 @@ -export const CHAIN_INDICATOR_IDS = [ 'daily_txs', 'coin_price', 'secondary_coin_price', 'market_cap', 'tvl' ] as const; +export const CHAIN_INDICATOR_IDS = [ 'daily_txs', 'daily_operational_txs', 'coin_price', 'secondary_coin_price', 'market_cap', 'tvl' ] as const; export type ChainIndicatorId = typeof CHAIN_INDICATOR_IDS[number]; export const HOME_STATS_WIDGET_IDS = [ @@ -6,6 +6,7 @@ export const HOME_STATS_WIDGET_IDS = [ 'total_blocks', 'average_block_time', 'total_txs', + 'total_operational_txs', 'latest_l1_state_batch', 'wallet_addresses', 'gas_tracker', diff --git a/ui/home/Stats.pw.tsx b/ui/home/Stats.pw.tsx index 9add012cba..f5818f18ca 100644 --- a/ui/home/Stats.pw.tsx +++ b/ui/home/Stats.pw.tsx @@ -12,6 +12,7 @@ test.describe('all items', () => { test.beforeEach(async({ render, mockApiResponse, mockEnvs }) => { await mockEnvs([ [ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_blocks","average_block_time","total_txs","wallet_addresses","gas_tracker","btc_locked"]' ], + [ 'NEXT_PUBLIC_STATS_API_HOST', '' ], ]); await mockApiResponse('stats', statsMock.withBtcLocked); component = await render(); @@ -22,7 +23,10 @@ test.describe('all items', () => { }); }); -test('no gas info', async({ render, mockApiResponse }) => { +test('no gas info', async({ render, mockApiResponse, mockEnvs }) => { + await mockEnvs([ + [ 'NEXT_PUBLIC_STATS_API_HOST', '' ], + ]); await mockApiResponse('stats', statsMock.withoutGasInfo); const component = await render(); @@ -32,6 +36,7 @@ test('no gas info', async({ render, mockApiResponse }) => { test('4 items default view +@mobile -@default', async({ render, mockApiResponse, mockEnvs }) => { await mockEnvs([ [ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_txs","gas_tracker","wallet_addresses","total_blocks"]' ], + [ 'NEXT_PUBLIC_STATS_API_HOST', '' ], ]); await mockApiResponse('stats', statsMock.base); const component = await render(); @@ -41,6 +46,7 @@ test('4 items default view +@mobile -@default', async({ render, mockApiResponse, test('3 items default view +@mobile -@default', async({ render, mockApiResponse, mockEnvs }) => { await mockEnvs([ [ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_txs","wallet_addresses","total_blocks"]' ], + [ 'NEXT_PUBLIC_STATS_API_HOST', '' ], ]); await mockApiResponse('stats', statsMock.base); const component = await render(); diff --git a/ui/home/Stats.tsx b/ui/home/Stats.tsx index ab00e80cf1..5ce4e3cc81 100644 --- a/ui/home/Stats.tsx +++ b/ui/home/Stats.tsx @@ -7,7 +7,7 @@ import type { HomeStatsWidgetId } from 'types/homepage'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; import { WEI } from 'lib/consts'; -import { HOMEPAGE_STATS } from 'stubs/stats'; +import { HOMEPAGE_STATS, HOMEPAGE_STATS_MICROSERVICE } from 'stubs/stats'; import GasInfoTooltip from 'ui/shared/gas/GasInfoTooltip'; import GasPrice from 'ui/shared/gas/GasPrice'; import IconSvg from 'ui/shared/IconSvg'; @@ -15,18 +15,31 @@ import type { Props as StatsWidgetProps } from 'ui/shared/stats/StatsWidget'; import StatsWidget from 'ui/shared/stats/StatsWidget'; const rollupFeature = config.features.rollup; +const isStatsFeatureEnabled = config.features.stats.isEnabled; const Stats = () => { const [ hasGasTracker, setHasGasTracker ] = React.useState(config.features.gasTracker.isEnabled); - const { data, isPlaceholderData, isError, dataUpdatedAt } = useApiQuery('stats', { + + // data from stats microservice is prioritized over data from stats api + const statsQuery = useApiQuery('stats_main', { + queryOptions: { + refetchOnMount: false, + placeholderData: isStatsFeatureEnabled ? HOMEPAGE_STATS_MICROSERVICE : undefined, + enabled: isStatsFeatureEnabled, + }, + }); + + const apiQuery = useApiQuery('stats', { queryOptions: { refetchOnMount: false, placeholderData: HOMEPAGE_STATS, }, }); + const isPlaceholderData = statsQuery.isPlaceholderData || apiQuery.isPlaceholderData; + React.useEffect(() => { - if (!isPlaceholderData && !data?.gas_prices?.average) { + if (!isPlaceholderData && !apiQuery.data?.gas_prices?.average) { setHasGasTracker(false); } // should run only after initial fetch @@ -69,7 +82,7 @@ const Stats = () => { } })(); - if (isError || latestBatchQuery?.isError) { + if (apiQuery.isError || statsQuery.isError || latestBatchQuery?.isError) { return null; } @@ -79,13 +92,16 @@ const Stats = () => { id: HomeStatsWidgetId; } + const apiData = apiQuery.data; + const statsData = statsQuery.data; + const items: Array = (() => { - if (!data) { + if (!statsData && !apiData) { return []; } - const gasInfoTooltip = hasGasTracker && data.gas_prices && data.gas_prices.average ? ( - + const gasInfoTooltip = hasGasTracker && apiData?.gas_prices && apiData.gas_prices.average ? ( + { href: { pathname: '/batches' as const }, isLoading, }, - { + (statsData?.total_blocks?.value || apiData?.total_blocks) && { id: 'total_blocks' as const, icon: 'block_slim' as const, - label: 'Total blocks', - value: Number(data.total_blocks).toLocaleString(), + label: statsData?.total_blocks?.title || 'Total blocks', + value: Number(statsData?.total_blocks?.value || apiData?.total_blocks).toLocaleString(), href: { pathname: '/blocks' as const }, isLoading, }, - { + (statsData?.average_block_time?.value || apiData?.average_block_time) && { id: 'average_block_time' as const, icon: 'clock-light' as const, - label: 'Average block time', - value: `${ (data.average_block_time / 1000).toFixed(1) }s`, + label: statsData?.average_block_time?.title || 'Average block time', + value: `${ + statsData?.average_block_time?.value ? + Number(statsData.average_block_time.value).toFixed(1) : + (apiData!.average_block_time / 1000).toFixed(1) + }s`, isLoading, }, - { + (statsData?.total_transactions?.value || apiData?.total_transactions) && { id: 'total_txs' as const, icon: 'transactions_slim' as const, - label: 'Total transactions', - value: Number(data.total_transactions).toLocaleString(), + label: statsData?.total_transactions?.title || 'Total transactions', + value: Number(statsData?.total_transactions?.value || apiData?.total_transactions).toLocaleString(), + href: { pathname: '/txs' as const }, + isLoading, + }, + statsData?.total_operational_transactions?.value && { + id: 'total_operational_txs' as const, + icon: 'transactions_slim' as const, + label: statsData?.total_operational_transactions?.title || 'Total operational transactions', + value: Number(statsData?.total_operational_transactions?.value).toLocaleString(), href: { pathname: '/txs' as const }, isLoading, }, - data.last_output_root_size && { + apiData?.last_output_root_size && { id: 'latest_l1_state_batch' as const, icon: 'txn_batches_slim' as const, label: 'Latest L1 state batch', - value: data.last_output_root_size, + value: apiData?.last_output_root_size, href: { pathname: '/batches' as const }, isLoading, }, - { + (statsData?.total_addresses?.value || apiData?.total_addresses) && { id: 'wallet_addresses' as const, icon: 'wallet' as const, - label: 'Wallet addresses', - value: Number(data.total_addresses).toLocaleString(), + label: statsData?.total_addresses?.title || 'Wallet addresses', + value: Number(statsData?.total_addresses?.value || apiData?.total_addresses).toLocaleString(), isLoading, }, - hasGasTracker && data.gas_prices && { + hasGasTracker && apiData?.gas_prices && { id: 'gas_tracker' as const, icon: 'gas' as const, label: 'Gas tracker', - value: data.gas_prices.average ? : 'N/A', + value: apiData.gas_prices.average ? : 'N/A', hint: gasInfoTooltip, isLoading, }, - data.rootstock_locked_btc && { + apiData?.rootstock_locked_btc && { id: 'btc_locked' as const, icon: 'coins/bitcoin' as const, label: 'BTC Locked in 2WP', - value: `${ BigNumber(data.rootstock_locked_btc).div(WEI).dp(0).toFormat() } RBTC`, + value: `${ BigNumber(apiData.rootstock_locked_btc).div(WEI).dp(0).toFormat() } RBTC`, isLoading, }, - data.celo && { + apiData?.celo && { id: 'current_epoch' as const, icon: 'hourglass' as const, label: 'Current epoch', - value: `#${ data.celo.epoch_number }`, + value: `#${ apiData.celo.epoch_number }`, isLoading, }, ] diff --git a/ui/home/indicators/ChainIndicatorChartContainer.tsx b/ui/home/indicators/ChainIndicatorChartContainer.tsx index a8bc2d2192..a435aa16f0 100644 --- a/ui/home/indicators/ChainIndicatorChartContainer.tsx +++ b/ui/home/indicators/ChainIndicatorChartContainer.tsx @@ -1,5 +1,4 @@ -import { chakra, Flex, Box } from '@chakra-ui/react'; -import type { UseQueryResult } from '@tanstack/react-query'; +import { chakra, Box } from '@chakra-ui/react'; import React from 'react'; import type { TimeChartData } from 'ui/shared/chart/types'; @@ -7,33 +6,33 @@ import type { TimeChartData } from 'ui/shared/chart/types'; import ContentLoader from 'ui/shared/ContentLoader'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; -import ChainIndicatorChart from './ChainIndicatorChart'; +import ChainIndicatorChartContent from './ChainIndicatorChartContent'; -type Props = UseQueryResult; +type Props = { + data: TimeChartData; + isError: boolean; + isPending: boolean; +}; const ChainIndicatorChartContainer = ({ data, isError, isPending }: Props) => { - const content = (() => { - if (isPending) { - return ; - } - - if (isError) { - return ; - } + if (isPending) { + return ; + } - if (data[0].items.length === 0) { - return no data; - } + if (isError) { + return ; + } - return ( - - - - ); - })(); + if (data[0].items.length === 0) { + return no data; + } - return { content }; + return ( + + + + ); }; export default React.memo(ChainIndicatorChartContainer); diff --git a/ui/home/indicators/ChainIndicatorChart.tsx b/ui/home/indicators/ChainIndicatorChartContent.tsx similarity index 94% rename from ui/home/indicators/ChainIndicatorChart.tsx rename to ui/home/indicators/ChainIndicatorChartContent.tsx index daf0251317..a167099170 100644 --- a/ui/home/indicators/ChainIndicatorChart.tsx +++ b/ui/home/indicators/ChainIndicatorChartContent.tsx @@ -16,7 +16,7 @@ interface Props { const CHART_MARGIN = { bottom: 5, left: 10, right: 10, top: 5 }; -const ChainIndicatorChart = ({ data }: Props) => { +const ChainIndicatorChartContent = ({ data }: Props) => { const overlayRef = React.useRef(null); const lineColor = useToken('colors', 'blue.500'); @@ -64,4 +64,4 @@ const ChainIndicatorChart = ({ data }: Props) => { ); }; -export default React.memo(ChainIndicatorChart); +export default React.memo(ChainIndicatorChartContent); diff --git a/ui/home/indicators/ChainIndicatorItem.tsx b/ui/home/indicators/ChainIndicatorItem.tsx index 7b9606d924..13e0da03e9 100644 --- a/ui/home/indicators/ChainIndicatorItem.tsx +++ b/ui/home/indicators/ChainIndicatorItem.tsx @@ -1,25 +1,23 @@ import { Text, Flex, Box, useColorModeValue } from '@chakra-ui/react'; -import type { UseQueryResult } from '@tanstack/react-query'; import React from 'react'; -import type { HomeStats } from 'types/api/stats'; import type { ChainIndicatorId } from 'types/homepage'; -import type { ResourceError } from 'lib/api/resources'; import Skeleton from 'ui/shared/chakra/Skeleton'; interface Props { id: ChainIndicatorId; title: string; - value: (stats: HomeStats) => string; - valueDiff?: (stats?: HomeStats) => number | null | undefined; + value?: string; + valueDiff?: number | null | undefined; icon: React.ReactNode; isSelected: boolean; onClick: (id: ChainIndicatorId) => void; - stats: UseQueryResult>; + isLoading: boolean; + hasData: boolean; } -const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onClick, stats }: Props) => { +const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onClick, isLoading, hasData }: Props) => { const activeColor = useColorModeValue('gray.500', 'gray.400'); const activeBgColor = useColorModeValue('white', 'black'); @@ -28,32 +26,28 @@ const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onC }, [ id, onClick ]); const valueContent = (() => { - if (!stats.data) { + if (!hasData) { return no data; } return ( - - { value(stats.data) } + + { value } ); })(); const valueDiffContent = (() => { - if (!valueDiff) { - return null; - } - const diff = valueDiff(stats.data); - if (diff === undefined || diff === null) { + if (valueDiff === undefined || valueDiff === null) { return null; } - const diffColor = diff >= 0 ? 'green.500' : 'red.500'; + const diffColor = valueDiff >= 0 ? 'green.500' : 'red.500'; return ( - - { diff >= 0 ? '+' : '-' } - { Math.abs(diff) }% + + { valueDiff >= 0 ? '+' : '-' } + { Math.abs(valueDiff) }% ); })(); diff --git a/ui/home/indicators/ChainIndicators.pw.tsx b/ui/home/indicators/ChainIndicators.pw.tsx index a1454282b6..0ae0c70f85 100644 --- a/ui/home/indicators/ChainIndicators.pw.tsx +++ b/ui/home/indicators/ChainIndicators.pw.tsx @@ -11,6 +11,7 @@ test.beforeEach(async({ mockEnvs }) => { await mockEnvs([ [ 'NEXT_PUBLIC_HOMEPAGE_CHARTS', '["daily_txs","coin_price","secondary_coin_price","market_cap","tvl"]' ], [ 'NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL', 'DUCK' ], + [ 'NEXT_PUBLIC_STATS_API_HOST', '' ], ]); }); diff --git a/ui/home/indicators/ChainIndicators.tsx b/ui/home/indicators/ChainIndicators.tsx index 698b053555..c9be478dc7 100644 --- a/ui/home/indicators/ChainIndicators.tsx +++ b/ui/home/indicators/ChainIndicators.tsx @@ -1,18 +1,24 @@ import { Flex, Text, useColorModeValue } from '@chakra-ui/react'; import React from 'react'; +import type { TChainIndicator } from './types'; +import type { ChainIndicatorId } from 'types/homepage'; + import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; -import { HOMEPAGE_STATS } from 'stubs/stats'; +import { HOMEPAGE_STATS, HOMEPAGE_STATS_MICROSERVICE } from 'stubs/stats'; import Skeleton from 'ui/shared/chakra/Skeleton'; import Hint from 'ui/shared/Hint'; import IconSvg from 'ui/shared/IconSvg'; import ChainIndicatorChartContainer from './ChainIndicatorChartContainer'; import ChainIndicatorItem from './ChainIndicatorItem'; -import useFetchChartData from './useFetchChartData'; +import useChartDataQuery from './useChartDataQuery'; +import getIndicatorValues from './utils/getIndicatorValues'; import INDICATORS from './utils/indicators'; +const isStatsFeatureEnabled = config.features.stats.isEnabled; + const indicators = INDICATORS .filter(({ id }) => config.UI.homepage.charts.includes(id)) .sort((a, b) => { @@ -29,10 +35,19 @@ const indicators = INDICATORS const ChainIndicators = () => { const [ selectedIndicator, selectIndicator ] = React.useState(indicators[0]?.id); - const indicator = indicators.find(({ id }) => id === selectedIndicator); + const selectedIndicatorData = indicators.find(({ id }) => id === selectedIndicator); + + const queryResult = useChartDataQuery(selectedIndicatorData?.id as ChainIndicatorId); - const queryResult = useFetchChartData(indicator); - const statsQueryResult = useApiQuery('stats', { + const statsMicroserviceQueryResult = useApiQuery('stats_main', { + queryOptions: { + refetchOnMount: false, + enabled: isStatsFeatureEnabled, + placeholderData: HOMEPAGE_STATS_MICROSERVICE, + }, + }); + + const statsApiQueryResult = useApiQuery('stats', { queryOptions: { refetchOnMount: false, placeholderData: HOMEPAGE_STATS, @@ -45,38 +60,57 @@ const ChainIndicators = () => { return null; } + const isPlaceholderData = (isStatsFeatureEnabled && statsMicroserviceQueryResult.isPlaceholderData) || statsApiQueryResult.isPlaceholderData; + const hasData = Boolean(statsApiQueryResult?.data || statsMicroserviceQueryResult?.data); + + const { value: indicatorValue, valueDiff: indicatorValueDiff } = + getIndicatorValues(selectedIndicatorData as TChainIndicator, statsMicroserviceQueryResult?.data, statsApiQueryResult?.data); + + const title = (() => { + let title: string | undefined; + if (isStatsFeatureEnabled && selectedIndicatorData?.titleMicroservice && statsMicroserviceQueryResult?.data) { + title = selectedIndicatorData.titleMicroservice(statsMicroserviceQueryResult.data); + } + + return title || selectedIndicatorData?.title; + })(); + + const hint = (() => { + let hint: string | undefined; + if (isStatsFeatureEnabled && selectedIndicatorData?.hintMicroservice && statsMicroserviceQueryResult?.data) { + hint = selectedIndicatorData.hintMicroservice(statsMicroserviceQueryResult.data); + } + + return hint || selectedIndicatorData?.hint; + })(); + const valueTitle = (() => { - if (statsQueryResult.isPlaceholderData) { + if (isPlaceholderData) { return ; } - if (!statsQueryResult.data) { + if (!hasData) { return There is no data; } return ( - { indicator?.value(statsQueryResult.data) } + { indicatorValue } ); })(); const valueDiff = (() => { - if (!statsQueryResult.data || !indicator?.valueDiff) { + if (indicatorValueDiff === undefined || indicatorValueDiff === null) { return null; } - const diff = indicator.valueDiff(statsQueryResult.data); - if (diff === undefined || diff === null) { - return null; - } - - const diffColor = diff >= 0 ? 'green.500' : 'red.500'; + const diffColor = indicatorValueDiff >= 0 ? 'green.500' : 'red.500'; return ( - - - { diff }% + + + { indicatorValueDiff }% ); })(); @@ -95,14 +129,16 @@ const ChainIndicators = () => { > - { indicator?.title } - { indicator?.hint && } + { title } + { hint && } { valueTitle } { valueDiff } - + + + { indicators.length > 1 && ( { { indicators.map((indicator) => ( )) } diff --git a/ui/home/indicators/types.ts b/ui/home/indicators/types.ts index 5fcfcc86f2..9bb8ad132c 100644 --- a/ui/home/indicators/types.ts +++ b/ui/home/indicators/types.ts @@ -1,22 +1,17 @@ import type React from 'react'; +import type { MainPageStats } from '@blockscout/stats-types'; import type { HomeStats } from 'types/api/stats'; import type { ChainIndicatorId } from 'types/homepage'; -import type { TimeChartData } from 'ui/shared/chart/types'; -import type { ResourcePayload } from 'lib/api/resources'; - -export type ChartsResources = 'stats_charts_txs' | 'stats_charts_market' | 'stats_charts_secondary_coin_price'; - -export interface TChainIndicator { +export interface TChainIndicator { id: ChainIndicatorId; + titleMicroservice?: (stats: MainPageStats) => string | undefined; title: string; value: (stats: HomeStats) => string; + valueMicroservice?: (stats: MainPageStats) => string | undefined; valueDiff?: (stats?: HomeStats) => number | null | undefined; icon: React.ReactNode; hint?: string; - api: { - resourceName: R; - dataFn: (response: ResourcePayload) => TimeChartData; - }; + hintMicroservice?: (stats: MainPageStats) => string | undefined; } diff --git a/ui/home/indicators/useChartDataQuery.tsx b/ui/home/indicators/useChartDataQuery.tsx new file mode 100644 index 0000000000..e30d01126e --- /dev/null +++ b/ui/home/indicators/useChartDataQuery.tsx @@ -0,0 +1,172 @@ +import type { ChainIndicatorId } from 'types/homepage'; +import type { TimeChartData, TimeChartDataItem, TimeChartItemRaw } from 'ui/shared/chart/types'; + +import config from 'configs/app'; +import useApiQuery from 'lib/api/useApiQuery'; + +import prepareChartItems from './utils/prepareChartItems'; + +const CHART_ITEMS: Record> = { + daily_txs: { + name: 'Tx/day', + valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + }, + daily_operational_txs: { + name: 'Tx/day', + valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + }, + coin_price: { + name: `${ config.chain.currency.symbol } price`, + valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), + }, + secondary_coin_price: { + name: `${ config.chain.currency.symbol } price`, + valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), + }, + market_cap: { + name: 'Market cap', + valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2 }), + }, + tvl: { + name: 'TVL', + valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + }, +}; + +const isStatsFeatureEnabled = config.features.stats.isEnabled; + +type UseFetchChartDataResult = { + isError: boolean; + isPending: boolean; + data: TimeChartData; +}; + +function getChartData(indicatorId: ChainIndicatorId, data: Array): TimeChartData { + return [ { + items: prepareChartItems(data), + name: CHART_ITEMS[indicatorId].name, + valueFormatter: CHART_ITEMS[indicatorId].valueFormatter, + } ]; +} + +export default function useChartDataQuery(indicatorId: ChainIndicatorId): UseFetchChartDataResult { + const statsDailyTxsQuery = useApiQuery('stats_main', { + queryOptions: { + refetchOnMount: false, + enabled: isStatsFeatureEnabled && indicatorId === 'daily_txs', + select: (data) => data.daily_new_transactions?.chart.map((item) => ({ date: new Date(item.date), value: Number(item.value) })) || [], + }, + }); + + const statsDailyOperationalTxsQuery = useApiQuery('stats_main', { + queryOptions: { + refetchOnMount: false, + enabled: isStatsFeatureEnabled && indicatorId === 'daily_operational_txs', + select: (data) => data.daily_new_operational_transactions?.chart.map((item) => ({ date: new Date(item.date), value: Number(item.value) })) || [], + }, + }); + + const apiDailyTxsQuery = useApiQuery('stats_charts_txs', { + queryOptions: { + refetchOnMount: false, + enabled: !isStatsFeatureEnabled && indicatorId === 'daily_txs', + select: (data) => data.chart_data.map((item) => ({ date: new Date(item.date), value: item.transaction_count })), + }, + }); + + const coinPriceQuery = useApiQuery('stats_charts_market', { + queryOptions: { + refetchOnMount: false, + enabled: indicatorId === 'coin_price', + select: (data) => data.chart_data.map((item) => ({ date: new Date(item.date), value: item.closing_price })), + }, + }); + + const secondaryCoinPriceQuery = useApiQuery('stats_charts_secondary_coin_price', { + queryOptions: { + refetchOnMount: false, + enabled: indicatorId === 'secondary_coin_price', + select: (data) => data.chart_data.map((item) => ({ date: new Date(item.date), value: item.closing_price })), + }, + }); + + const marketCapQuery = useApiQuery('stats_charts_market', { + queryOptions: { + refetchOnMount: false, + enabled: indicatorId === 'market_cap', + select: (data) => data.chart_data.map((item) => ( + { + date: new Date(item.date), + value: (() => { + if (item.market_cap !== undefined) { + return item.market_cap; + } + + if (item.closing_price === null) { + return null; + } + + return Number(item.closing_price) * Number(data.available_supply); + })(), + })), + }, + }); + + const tvlQuery = useApiQuery('stats_charts_market', { + queryOptions: { + refetchOnMount: false, + enabled: indicatorId === 'tvl', + select: (data) => data.chart_data.map((item) => ( + { + date: new Date(item.date), + value: item.tvl !== undefined ? item.tvl : 0, + })), + }, + }); + + switch (indicatorId) { + case 'daily_txs': { + const query = isStatsFeatureEnabled ? statsDailyTxsQuery : apiDailyTxsQuery; + return { + data: getChartData(indicatorId, query.data || []), + isError: query.isError, + isPending: query.isPending, + }; + } + case 'daily_operational_txs': { + return { + data: getChartData(indicatorId, statsDailyOperationalTxsQuery.data || []), + isError: statsDailyOperationalTxsQuery.isError, + isPending: statsDailyOperationalTxsQuery.isPending, + }; + } + case 'coin_price': { + return { + data: getChartData(indicatorId, coinPriceQuery.data || []), + isError: coinPriceQuery.isError, + isPending: coinPriceQuery.isPending, + }; + } + case 'secondary_coin_price': { + return { + data: getChartData(indicatorId, secondaryCoinPriceQuery.data || []), + isError: secondaryCoinPriceQuery.isError, + isPending: secondaryCoinPriceQuery.isPending, + }; + } + case 'market_cap': { + return { + data: getChartData(indicatorId, marketCapQuery.data || []), + isError: marketCapQuery.isError, + isPending: marketCapQuery.isPending, + }; + } + case 'tvl': { + return { + data: getChartData(indicatorId, tvlQuery.data || []), + isError: tvlQuery.isError, + isPending: tvlQuery.isPending, + }; + } + } +} diff --git a/ui/home/indicators/useFetchChartData.tsx b/ui/home/indicators/useFetchChartData.tsx deleted file mode 100644 index ffebc2b685..0000000000 --- a/ui/home/indicators/useFetchChartData.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { UseQueryResult } from '@tanstack/react-query'; -import React from 'react'; - -import type { TChainIndicator, ChartsResources } from './types'; -import type { TimeChartData } from 'ui/shared/chart/types'; - -import type { ResourcePayload } from 'lib/api/resources'; -import useApiQuery from 'lib/api/useApiQuery'; - -export default function useFetchChartData(indicator: TChainIndicator | undefined): UseQueryResult { - const queryResult = useApiQuery(indicator?.api.resourceName || 'stats_charts_txs', { - queryOptions: { enabled: Boolean(indicator) }, - }); - - return React.useMemo(() => { - return { - ...queryResult, - data: queryResult.data && indicator ? indicator.api.dataFn(queryResult.data as ResourcePayload) : queryResult.data, - } as UseQueryResult; - }, [ indicator, queryResult ]); -} diff --git a/ui/home/indicators/utils/getIndicatorValues.ts b/ui/home/indicators/utils/getIndicatorValues.ts new file mode 100644 index 0000000000..1d3b7a51a2 --- /dev/null +++ b/ui/home/indicators/utils/getIndicatorValues.ts @@ -0,0 +1,28 @@ +import type { TChainIndicator } from '../types'; +import type * as stats from '@blockscout/stats-types'; +import type { HomeStats } from 'types/api/stats'; + +import config from 'configs/app'; + +export default function getIndicatorValues(indicator: TChainIndicator, statsData?: stats.MainPageStats, statsApiData?: HomeStats) { + const value = (() => { + if (config.features.stats.isEnabled && indicator?.valueMicroservice && statsData) { + return indicator.valueMicroservice(statsData); + } + + if (statsApiData) { + return indicator?.value(statsApiData); + } + + return 'N/A'; + })(); + + // we have diffs only for coin and second coin price charts that get data from stats api + // so we don't check microservice data here, but may require to add it in the future + const valueDiff = indicator?.valueDiff ? indicator.valueDiff(statsApiData) : undefined; + + return { + value, + valueDiff, + }; +} diff --git a/ui/home/indicators/utils/indicators.tsx b/ui/home/indicators/utils/indicators.tsx index b1fbcd5cc9..08b91111cd 100644 --- a/ui/home/indicators/utils/indicators.tsx +++ b/ui/home/indicators/utils/indicators.tsx @@ -1,160 +1,77 @@ import React from 'react'; import type { TChainIndicator } from '../types'; -import type { TimeChartItem, TimeChartItemRaw } from 'ui/shared/chart/types'; import config from 'configs/app'; -import { sortByDateDesc } from 'ui/shared/chart/utils/sorts'; import IconSvg from 'ui/shared/IconSvg'; import NativeTokenIcon from 'ui/shared/NativeTokenIcon'; -const nonNullTailReducer = (result: Array, item: TimeChartItemRaw) => { - if (item.value === null && result.length === 0) { - return result; - } - result.unshift(item); - return result; -}; - -const mapNullToZero: (item: TimeChartItemRaw) => TimeChartItem = (item) => ({ ...item, value: Number(item.value) }); - -const dailyTxsIndicator: TChainIndicator<'stats_charts_txs'> = { - id: 'daily_txs', - title: 'Daily transactions', - value: (stats) => stats.transactions_today === null ? - 'N/A' : - Number(stats.transactions_today).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - icon: , - hint: `Number of transactions yesterday (0:00 - 23:59 UTC). The chart displays daily transactions for the past 30 days.`, - api: { - resourceName: 'stats_charts_txs', - dataFn: (response) => ([ { - items: response.chart_data - .map((item) => ({ date: new Date(item.date), value: item.transaction_count })) - .sort(sortByDateDesc) - .reduceRight(nonNullTailReducer, [] as Array) - .map(mapNullToZero), - name: 'Tx/day', - valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - } ]), +const INDICATORS: Array = [ + { + id: 'daily_txs', + title: 'Daily transactions', + titleMicroservice: (stats) => stats.daily_new_transactions?.info?.title, + value: (stats) => stats.transactions_today === null ? + 'N/A' : + Number(stats.transactions_today).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + valueMicroservice: (stats) => stats.yesterday_transactions?.value === null ? + 'N/A' : + Number(stats.yesterday_transactions?.value).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + icon: , + hint: `Number of transactions yesterday (0:00 - 23:59 UTC). The chart displays daily transactions for the past 30 days.`, + hintMicroservice: (stats) => stats.daily_new_transactions?.info?.description, }, -}; - -const coinPriceIndicator: TChainIndicator<'stats_charts_market'> = { - id: 'coin_price', - title: `${ config.chain.currency.symbol } price`, - value: (stats) => stats.coin_price === null ? - '$N/A' : - '$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), - valueDiff: (stats) => stats?.coin_price !== null ? stats?.coin_price_change_percentage : null, - icon: , - hint: `${ config.chain.currency.symbol } token daily price in USD.`, - api: { - resourceName: 'stats_charts_market', - dataFn: (response) => ([ { - items: response.chart_data - .map((item) => ({ date: new Date(item.date), value: item.closing_price })) - .sort(sortByDateDesc) - .reduceRight(nonNullTailReducer, [] as Array) - .map(mapNullToZero), - name: `${ config.chain.currency.symbol } price`, - valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), - } ]), + { + id: 'daily_operational_txs', + title: 'Daily op txns', + titleMicroservice: (stats) => stats.daily_new_operational_transactions?.info?.title, + value: () => 'N/A', + valueMicroservice: (stats) => stats.yesterday_operational_transactions?.value === null ? + 'N/A' : + Number(stats.yesterday_operational_transactions?.value).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + icon: , + hint: `Number of operational transactions yesterday (0:00 - 23:59 UTC). The chart displays daily operational transactions for the past 30 days.`, + hintMicroservice: (stats) => stats.daily_new_operational_transactions?.info?.description, }, -}; - -const secondaryCoinPriceIndicator: TChainIndicator<'stats_charts_secondary_coin_price'> = { - id: 'secondary_coin_price', - title: `${ config.chain.secondaryCoin.symbol } price`, - value: (stats) => !stats.secondary_coin_price || stats.secondary_coin_price === null ? - '$N/A' : - '$' + Number(stats.secondary_coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), - valueDiff: () => null, - icon: , - hint: `${ config.chain.secondaryCoin.symbol } token daily price in USD.`, - api: { - resourceName: 'stats_charts_secondary_coin_price', - dataFn: (response) => ([ { - items: response.chart_data - .map((item) => ({ date: new Date(item.date), value: item.closing_price })) - .sort(sortByDateDesc) - .reduceRight(nonNullTailReducer, [] as Array) - .map(mapNullToZero), - name: `${ config.chain.secondaryCoin.symbol } price`, - valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), - } ]), + { + id: 'coin_price', + title: `${ config.chain.currency.symbol } price`, + value: (stats) => stats.coin_price === null ? + '$N/A' : + '$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), + valueDiff: (stats) => stats?.coin_price !== null ? stats?.coin_price_change_percentage : null, + icon: , + hint: `${ config.chain.currency.symbol } token daily price in USD.`, }, -}; - -const marketPriceIndicator: TChainIndicator<'stats_charts_market'> = { - id: 'market_cap', - title: 'Market cap', - value: (stats) => stats.market_cap === null ? - '$N/A' : - '$' + Number(stats.market_cap).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - icon: , - // eslint-disable-next-line max-len - hint: 'The total market value of a cryptocurrency\'s circulating supply. It is analogous to the free-float capitalization in the stock market. Market Cap = Current Price x Circulating Supply.', - api: { - resourceName: 'stats_charts_market', - dataFn: (response) => ([ { - items: response.chart_data - .map((item) => ( - { - date: new Date(item.date), - value: (() => { - if (item.market_cap !== undefined) { - return item.market_cap; - } - - if (item.closing_price === null) { - return null; - } - - return Number(item.closing_price) * Number(response.available_supply); - })(), - })) - .sort(sortByDateDesc) - .reduceRight(nonNullTailReducer, [] as Array) - .map(mapNullToZero), - name: 'Market cap', - valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2 }), - } ]), + { + id: 'secondary_coin_price', + title: `${ config.chain.secondaryCoin.symbol } price`, + value: (stats) => !stats.secondary_coin_price || stats.secondary_coin_price === null ? + '$N/A' : + '$' + Number(stats.secondary_coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), + valueDiff: () => null, + icon: , + hint: `${ config.chain.secondaryCoin.symbol } token daily price in USD.`, }, -}; - -const tvlIndicator: TChainIndicator<'stats_charts_market'> = { - id: 'tvl', - title: 'Total value locked', - value: (stats) => stats.tvl === null ? - '$N/A' : - '$' + Number(stats.tvl).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - icon: , - hint: 'Total value of digital assets locked or staked in a chain', - api: { - resourceName: 'stats_charts_market', - dataFn: (response) => ([ { - items: response.chart_data - .map((item) => ( - { - date: new Date(item.date), - value: item.tvl !== undefined ? item.tvl : 0, - })) - .sort(sortByDateDesc) - .reduceRight(nonNullTailReducer, [] as Array) - .map(mapNullToZero), - name: 'TVL', - valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), - } ]), + { + id: 'market_cap', + title: 'Market cap', + value: (stats) => stats.market_cap === null ? + '$N/A' : + '$' + Number(stats.market_cap).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + icon: , + // eslint-disable-next-line max-len + hint: 'The total market value of a cryptocurrency\'s circulating supply. It is analogous to the free-float capitalization in the stock market. Market Cap = Current Price x Circulating Supply.', + }, + { + id: 'tvl', + title: 'Total value locked', + value: (stats) => stats.tvl === null ? + '$N/A' : + '$' + Number(stats.tvl).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), + icon: , + hint: 'Total value of digital assets locked or staked in a chain', }, -}; - -const INDICATORS = [ - dailyTxsIndicator, - coinPriceIndicator, - secondaryCoinPriceIndicator, - marketPriceIndicator, - tvlIndicator, ]; export default INDICATORS; diff --git a/ui/home/indicators/utils/prepareChartItems.ts b/ui/home/indicators/utils/prepareChartItems.ts new file mode 100644 index 0000000000..9f5cb73aab --- /dev/null +++ b/ui/home/indicators/utils/prepareChartItems.ts @@ -0,0 +1,20 @@ +import type { TimeChartItem, TimeChartItemRaw } from 'ui/shared/chart/types'; + +import { sortByDateDesc } from 'ui/shared/chart/utils/sorts'; + +const nonNullTailReducer = (result: Array, item: TimeChartItemRaw) => { + if (item.value === null && result.length === 0) { + return result; + } + result.unshift(item); + return result; +}; + +const mapNullToZero: (item: TimeChartItemRaw) => TimeChartItem = (item) => ({ ...item, value: Number(item.value) }); + +export default function prepareChartItems(items: Array) { + return items + .sort(sortByDateDesc) + .reduceRight(nonNullTailReducer, [] as Array) + .map(mapNullToZero); +} diff --git a/ui/pages/Home.pw.tsx b/ui/pages/Home.pw.tsx index 51630da274..d33ce54c0e 100644 --- a/ui/pages/Home.pw.tsx +++ b/ui/pages/Home.pw.tsx @@ -4,6 +4,7 @@ import React from 'react'; import * as blockMock from 'mocks/blocks/block'; import * as dailyTxsMock from 'mocks/stats/daily_txs'; import * as statsMock from 'mocks/stats/index'; +import * as statsMainMock from 'mocks/stats/main'; import * as txMock from 'mocks/txs/tx'; import { test, expect, devices } from 'playwright/lib'; import * as pwConfig from 'playwright/utils/config'; @@ -15,6 +16,7 @@ test.describe('default view', () => { test.beforeEach(async({ render, mockApiResponse, mockAssetResponse }) => { await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg'); + await mockApiResponse('stats_main', statsMainMock.base); await mockApiResponse('stats', statsMock.base); await mockApiResponse('homepage_blocks', [ blockMock.base, @@ -55,6 +57,7 @@ test.describe('mobile', () => { test('base view', async({ render, page, mockAssetResponse, mockApiResponse }) => { await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg'); + await mockApiResponse('stats_main', statsMainMock.base); await mockApiResponse('stats', statsMock.base); await mockApiResponse('homepage_blocks', [ blockMock.base, diff --git a/ui/pages/VerifiedContracts.pw.tsx b/ui/pages/VerifiedContracts.pw.tsx index 7a07731b5c..b5ea870350 100644 --- a/ui/pages/VerifiedContracts.pw.tsx +++ b/ui/pages/VerifiedContracts.pw.tsx @@ -6,7 +6,8 @@ import { test, expect } from 'playwright/lib'; import VerifiedContracts from './VerifiedContracts'; -test('base view +@mobile', async({ render, mockTextAd, mockApiResponse }) => { +test('base view +@mobile', async({ render, mockTextAd, mockApiResponse, mockEnvs }) => { + await mockEnvs([ [ 'NEXT_PUBLIC_STATS_API_HOST', '' ] ]); await mockTextAd(); await mockApiResponse('verified_contracts', verifiedContractsMock.baseResponse); await mockApiResponse('verified_contracts_counters', verifiedContractsCountersMock); diff --git a/ui/pages/__screenshots__/Home.pw.tsx_dark-color-mode_default-view---default-dark-mode-1.png b/ui/pages/__screenshots__/Home.pw.tsx_dark-color-mode_default-view---default-dark-mode-1.png index c0ef85b8aa..7f2a7782cf 100644 Binary files a/ui/pages/__screenshots__/Home.pw.tsx_dark-color-mode_default-view---default-dark-mode-1.png and b/ui/pages/__screenshots__/Home.pw.tsx_dark-color-mode_default-view---default-dark-mode-1.png differ diff --git a/ui/pages/__screenshots__/Home.pw.tsx_default_default-view-screen-xl-base-view-1.png b/ui/pages/__screenshots__/Home.pw.tsx_default_default-view-screen-xl-base-view-1.png index 9d4a7547f9..5533377265 100644 Binary files a/ui/pages/__screenshots__/Home.pw.tsx_default_default-view-screen-xl-base-view-1.png and b/ui/pages/__screenshots__/Home.pw.tsx_default_default-view-screen-xl-base-view-1.png differ diff --git a/ui/pages/__screenshots__/Home.pw.tsx_default_mobile-base-view-1.png b/ui/pages/__screenshots__/Home.pw.tsx_default_mobile-base-view-1.png index 6ae75f1176..ade8a7070a 100644 Binary files a/ui/pages/__screenshots__/Home.pw.tsx_default_mobile-base-view-1.png and b/ui/pages/__screenshots__/Home.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/shared/stats/StatsWidget.tsx b/ui/shared/stats/StatsWidget.tsx index e6987e8c4f..7ebca78cf4 100644 --- a/ui/shared/stats/StatsWidget.tsx +++ b/ui/shared/stats/StatsWidget.tsx @@ -20,7 +20,7 @@ export type Props = { diff?: string | number; diffFormatted?: string; diffPeriod?: '24h'; - period?: '1h' | '24h'; + period?: '1h' | '24h' | '30min'; href?: Route; icon?: IconName; }; diff --git a/ui/txs/TxsStats.pw.tsx b/ui/txs/TxsStats.pw.tsx index 88d11e3552..34c06591c5 100644 --- a/ui/txs/TxsStats.pw.tsx +++ b/ui/txs/TxsStats.pw.tsx @@ -6,7 +6,8 @@ import { test, expect } from 'playwright/lib'; import TxsStats from './TxsStats'; -test('base view +@mobile', async({ render, mockApiResponse }) => { +test('base view +@mobile', async({ render, mockApiResponse, mockEnvs }) => { + await mockEnvs([ [ 'NEXT_PUBLIC_STATS_API_HOST', '' ] ]); await mockApiResponse('stats', statsMock.base); await mockApiResponse('txs_stats', txsStatsMock.base); const component = await render(); diff --git a/ui/txs/TxsStats.tsx b/ui/txs/TxsStats.tsx index 763d6eada7..176cf37459 100644 --- a/ui/txs/TxsStats.tsx +++ b/ui/txs/TxsStats.tsx @@ -6,13 +6,23 @@ import useApiQuery from 'lib/api/useApiQuery'; import getCurrencyValue from 'lib/getCurrencyValue'; import { thinsp } from 'lib/html-entities'; import { HOMEPAGE_STATS } from 'stubs/stats'; -import { TXS_STATS } from 'stubs/tx'; +import { TXS_STATS, TXS_STATS_MICROSERVICE } from 'stubs/tx'; import StatsWidget from 'ui/shared/stats/StatsWidget'; +const isStatsFeatureEnabled = config.features.stats.isEnabled; + const TxsStats = () => { - const txsStatsQuery = useApiQuery('txs_stats', { + const txsStatsQuery = useApiQuery('stats_transactions', { + queryOptions: { + enabled: isStatsFeatureEnabled, + placeholderData: isStatsFeatureEnabled ? TXS_STATS_MICROSERVICE : undefined, + }, + }); + + const txsStatsApiQuery = useApiQuery('txs_stats', { queryOptions: { - placeholderData: TXS_STATS, + enabled: !isStatsFeatureEnabled, + placeholderData: !isStatsFeatureEnabled ? TXS_STATS : undefined, }, }); @@ -22,58 +32,94 @@ const TxsStats = () => { }, }); - if (!txsStatsQuery.data) { + if ((isStatsFeatureEnabled && !txsStatsQuery.data) || (!isStatsFeatureEnabled && !txsStatsApiQuery.data)) { return null; } - const txFeeAvg = getCurrencyValue({ - value: txsStatsQuery.data.transaction_fees_avg_24h, + const isLoading = isStatsFeatureEnabled ? txsStatsQuery.isPlaceholderData : txsStatsApiQuery.isPlaceholderData; + + const txCount24h = isStatsFeatureEnabled ? txsStatsQuery.data?.transactions_24h?.value : txsStatsApiQuery.data?.transactions_count_24h; + const operationalTxns24h = isStatsFeatureEnabled ? txsStatsQuery.data?.operational_transactions_24h?.value : null; + + const pendingTxns = isStatsFeatureEnabled ? txsStatsQuery.data?.pending_transactions_30m?.value : txsStatsApiQuery.data?.pending_transactions_count; + + // in microservice data, fee values are already divided by 10^decimals + const txFeeSum24h = isStatsFeatureEnabled ? + Number(txsStatsQuery.data?.transactions_fee_24h?.value) : + Number(txsStatsApiQuery.data?.transaction_fees_sum_24h) / (10 ** config.chain.currency.decimals); + + const avgFee = isStatsFeatureEnabled ? txsStatsQuery.data?.average_transactions_fee_24h?.value : txsStatsApiQuery.data?.transaction_fees_avg_24h; + + const txFeeAvg = avgFee ? getCurrencyValue({ + value: avgFee, exchangeRate: statsQuery.data?.coin_price, - decimals: String(config.chain.currency.decimals), + // in microservice data, fee values are already divided by 10^decimals + decimals: isStatsFeatureEnabled ? '0' : String(config.chain.currency.decimals), accuracyUsd: 2, - }); + }) : null; + + const itemsCount = [ + txCount24h, + operationalTxns24h, + pendingTxns, + txFeeSum24h, + txFeeAvg, + ].filter(Boolean).length; return ( - - - - + { txCount24h && ( + + ) } + { operationalTxns24h && ( + + ) } + { pendingTxns && ( + + ) } + { txFeeSum24h && ( + + ) } + { txFeeAvg && ( + + ) } ); }; diff --git a/ui/verifiedContracts/VerifiedContractsCounters.tsx b/ui/verifiedContracts/VerifiedContractsCounters.tsx index ba0c5e8a26..22d38699b7 100644 --- a/ui/verifiedContracts/VerifiedContractsCounters.tsx +++ b/ui/verifiedContracts/VerifiedContractsCounters.tsx @@ -3,37 +3,59 @@ import React from 'react'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; -import { VERIFIED_CONTRACTS_COUNTERS } from 'stubs/contract'; +import { VERIFIED_CONTRACTS_COUNTERS, VERIFIED_CONTRACTS_COUNTERS_MICROSERVICE } from 'stubs/contract'; import StatsWidget from 'ui/shared/stats/StatsWidget'; +const isStatsFeatureEnabled = config.features.stats.isEnabled; + const VerifiedContractsCounters = () => { - const countersQuery = useApiQuery('verified_contracts_counters', { + const countersStatsQuery = useApiQuery('stats_contracts', { + queryOptions: { + enabled: isStatsFeatureEnabled, + placeholderData: isStatsFeatureEnabled ? VERIFIED_CONTRACTS_COUNTERS_MICROSERVICE : undefined, + }, + }); + + const countersApiQuery = useApiQuery('verified_contracts_counters', { queryOptions: { - placeholderData: VERIFIED_CONTRACTS_COUNTERS, + enabled: !isStatsFeatureEnabled, + placeholderData: !isStatsFeatureEnabled ? VERIFIED_CONTRACTS_COUNTERS : undefined, }, }); - if (!countersQuery.data) { + if (!(isStatsFeatureEnabled ? countersStatsQuery.data : countersApiQuery.data)) { return null; } + const isLoading = isStatsFeatureEnabled ? countersStatsQuery.isPlaceholderData : countersApiQuery.isPlaceholderData; + + const contractsCount = isStatsFeatureEnabled ? countersStatsQuery.data?.total_contracts?.value : countersApiQuery.data?.smart_contracts; + const newContractsCount = isStatsFeatureEnabled ? countersStatsQuery.data?.new_contracts_24h?.value : countersApiQuery.data?.new_smart_contracts_24h; + + const verifiedContractsCount = isStatsFeatureEnabled ? + countersStatsQuery.data?.total_verified_contracts?.value : + countersApiQuery.data?.verified_smart_contracts; + const newVerifiedContractsCount = isStatsFeatureEnabled ? + countersStatsQuery.data?.new_verified_contracts_24h?.value : + countersApiQuery.data?.new_verified_smart_contracts_24h; + return ( diff --git a/yarn.lock b/yarn.lock index aefe673582..e03de57526 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1508,10 +1508,10 @@ resolved "https://registry.yarnpkg.com/@blockscout/bens-types/-/bens-types-1.4.1.tgz#9182a79d9015b7fa2339edf0bfa3cd0c32045e66" integrity sha512-TlZ1HVdZ2Cswm/CcvNoxS+Ydiht/YGaLo//PJR/UmkmihlEFoY4HfVJvVcUnOQXi+Si7FwJ486DPii889nTJsQ== -"@blockscout/stats-types@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-2.0.0.tgz#3805f8379b75377cde8a9ab76306af37bb735846" - integrity sha512-icYDsOHsDACjG/7VZhlV+1QRKSJOycblpswQ5Si0dqeWdOpbtmxSqolAS/z6C77d8p+uxZUCMjNa9otUCqn18A== +"@blockscout/stats-types@2.5.0-alpha": + version "2.5.0-alpha" + resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-2.5.0-alpha.tgz#e34698577a337ce08b176d8709f89f185d9d9359" + integrity sha512-B4IYeNt3pqIIJvcnkLIXm4LNN77VxTV1VYopJ8t6iFPT+JC3BSvRWSpMJMl7nV+WCLywcW27BKmYxdV9rR66bw== "@blockscout/visualizer-types@0.2.0": version "0.2.0"