diff --git a/configs/envs/.env.eth_sepolia b/configs/envs/.env.eth_sepolia index 1304b37c72..01698d12c4 100644 --- a/configs/envs/.env.eth_sepolia +++ b/configs/envs/.env.eth_sepolia @@ -14,7 +14,7 @@ NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" } NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com NEXT_PUBLIC_API_BASE_PATH=/ -NEXT_PUBLIC_API_HOST=eth-sepolia.blockscout.com +NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com @@ -59,7 +59,7 @@ NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-c NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://sepolia.drpc.org?ref=559183','text':'Public RPC'}] NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global NEXT_PUBLIC_SENTRY_ENABLE_TRACING=true -NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s.blockscout.com +NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s-dev.blockscout.com NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com \ No newline at end of file diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index b1a0bf5245..a3db79064e 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -248,7 +248,7 @@ export default function useNavItems(): ReturnType { text: 'Charts & stats', nextRoute: { pathname: '/stats' as const }, icon: 'stats', - isActive: pathname === '/stats', + isActive: pathname.startsWith('/stats'), } : null, apiNavItems.length > 0 && { text: 'API', diff --git a/lib/metadata/generate.ts b/lib/metadata/generate.ts index 9282da7fe7..ee9c3f55cd 100644 --- a/lib/metadata/generate.ts +++ b/lib/metadata/generate.ts @@ -20,7 +20,7 @@ export default function generate(route: Rout }; const title = compileValue(templates.title.make(route.pathname, Boolean(apiData)), params); - const description = compileValue(templates.description.make(route.pathname), params); + const description = compileValue(templates.description.make(route.pathname, Boolean(apiData)), params); const pageOgType = getPageOgType(route.pathname); diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index 1f8498fe69..7babaa72b8 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -23,6 +23,7 @@ const OG_TYPE_DICT: Record = { '/apps': 'Root page', '/apps/[id]': 'Regular page', '/stats': 'Root page', + '/stats/[id]': 'Regular page', '/api-docs': 'Regular page', '/graphiql': 'Regular page', '/search-results': 'Regular page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index e4d0ccd715..49351be252 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -27,6 +27,7 @@ const TEMPLATE_MAP: Record = { '/apps': DEFAULT_TEMPLATE, '/apps/[id]': DEFAULT_TEMPLATE, '/stats': DEFAULT_TEMPLATE, + '/stats/[id]': DEFAULT_TEMPLATE, '/api-docs': DEFAULT_TEMPLATE, '/graphiql': DEFAULT_TEMPLATE, '/search-results': DEFAULT_TEMPLATE, @@ -71,8 +72,10 @@ const TEMPLATE_MAP: Record = { '/auth/unverified-email': DEFAULT_TEMPLATE, }; -export function make(pathname: Route['pathname']) { - const template = TEMPLATE_MAP[pathname]; +const TEMPLATE_MAP_ENHANCED: Partial> = { + '/stats/[id]': '%description%', +}; - return template ?? ''; +export function make(pathname: Route['pathname'], isEnriched = false) { + return (isEnriched ? TEMPLATE_MAP_ENHANCED[pathname] : undefined) ?? TEMPLATE_MAP[pathname] ?? ''; } diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index e0c9d9e44c..c8ef390d71 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -23,6 +23,7 @@ const TEMPLATE_MAP: Record = { '/apps': '%network_name% DApps - Explore top apps', '/apps/[id]': '%network_name% marketplace app', '/stats': '%network_name% stats - %network_name% network insights', + '/stats/[id]': '%network_name% stats - %id% chart', '/api-docs': '%network_name% API docs - %network_name% developer tools', '/graphiql': 'GraphQL for %network_name% - %network_name% data query', '/search-results': '%network_name% search result for %q%', @@ -72,6 +73,7 @@ const TEMPLATE_MAP_ENHANCED: Partial> = { '/token/[hash]/instance/[id]': '%network_name% token instance for %symbol%', '/apps/[id]': '%network_name% - %app_name%', '/address/[hash]': '%network_name% address details for %domain_name%', + '/stats/[id]': '%title% chart on %network_name%', }; export function make(pathname: Route['pathname'], isEnriched = false) { diff --git a/lib/metadata/types.ts b/lib/metadata/types.ts index fda74301ba..ddb29c852a 100644 --- a/lib/metadata/types.ts +++ b/lib/metadata/types.ts @@ -1,3 +1,4 @@ +import type { LineChart } from '@blockscout/stats-types'; import type { TokenInfo } from 'types/api/token'; import type { Route } from 'nextjs-routes'; @@ -9,6 +10,7 @@ export type ApiData = Pathname extends '/token/[hash]' ? TokenInfo : Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } : Pathname extends '/apps/[id]' ? { app_name: string } : + Pathname extends '/stats/[id]' ? LineChart['info'] : never ) | null; diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index 3fc7896f81..af56fb6693 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -21,6 +21,7 @@ export const PAGE_TYPE_DICT: Record = { '/apps': 'DApps', '/apps/[id]': 'DApp', '/stats': 'Stats', + '/stats/[id]': 'Stats chart', '/api-docs': 'REST API', '/graphiql': 'GraphQL', '/search-results': 'Search results', diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index 4d59ec007e..f29756f1b3 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -116,6 +116,9 @@ Type extends EventTypes.PAGE_WIDGET ? ( 'Type': 'Address tag'; 'Info': string; 'URL': string; + } | { + 'Type': 'Share chart'; + 'Info': string; } ) : Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { diff --git a/mocks/stats/line.ts b/mocks/stats/line.ts index 799cff4c45..0c10eed840 100644 --- a/mocks/stats/line.ts +++ b/mocks/stats/line.ts @@ -4,158 +4,195 @@ export const averageGasPrice: stats.LineChart = { chart: [ { date: '2023-12-22', + date_to: '2023-12-22', value: '37.7804422597599', is_approximate: false, }, { date: '2023-12-23', + date_to: '2023-12-23', value: '25.84889883009387', is_approximate: false, }, { date: '2023-12-24', + date_to: '2023-12-24', value: '25.818463227198574', is_approximate: false, }, { date: '2023-12-25', + date_to: '2023-12-25', value: '26.045513050051298', is_approximate: false, }, { date: '2023-12-26', + date_to: '2023-12-26', value: '21.42600692652399', is_approximate: false, }, { date: '2023-12-27', + date_to: '2023-12-27', value: '31.066730409846656', is_approximate: false, }, { date: '2023-12-28', + date_to: '2023-12-28', value: '33.63955781902089', is_approximate: false, }, { date: '2023-12-29', + date_to: '2023-12-29', value: '28.064736756058384', is_approximate: false, }, { date: '2023-12-30', + date_to: '2023-12-30', value: '23.074500869678175', is_approximate: false, }, { date: '2023-12-31', + date_to: '2023-12-31', value: '17.651005734615133', is_approximate: false, }, { date: '2024-01-01', + date_to: '2023-01-01', value: '14.906085174476441', is_approximate: false, }, { date: '2024-01-02', + date_to: '2023-01-02', value: '22.28459059038656', is_approximate: false, }, { date: '2024-01-03', + date_to: '2023-01-03', value: '39.8311646806592', is_approximate: false, }, { date: '2024-01-04', + date_to: '2023-01-04', value: '26.09989322256083', is_approximate: false, }, { date: '2024-01-05', + date_to: '2023-01-05', value: '22.821996688111998', is_approximate: false, }, { date: '2024-01-06', + date_to: '2023-01-06', value: '20.32680041262083', is_approximate: false, }, { date: '2024-01-07', + date_to: '2023-01-07', value: '32.535045831809704', is_approximate: false, }, { date: '2024-01-08', + date_to: '2023-01-08', value: '27.443477102139482', is_approximate: false, }, { date: '2024-01-09', + date_to: '2023-01-09', value: '20.7911332558055', is_approximate: false, }, { date: '2024-01-10', + date_to: '2023-01-10', value: '42.10740192523919', is_approximate: false, }, { date: '2024-01-11', + date_to: '2023-01-11', value: '35.75215680343582', is_approximate: false, }, { date: '2024-01-12', + date_to: '2023-01-12', value: '27.430414798093253', is_approximate: false, }, { date: '2024-01-13', + date_to: '2023-01-13', value: '20.170934096589875', is_approximate: false, }, { date: '2024-01-14', + date_to: '2023-01-14', value: '38.79660984371034', is_approximate: false, }, { date: '2024-01-15', + date_to: '2023-01-15', value: '26.140740484554204', is_approximate: false, }, { date: '2024-01-16', + date_to: '2023-01-16', value: '36.708543184194156', is_approximate: false, }, { date: '2024-01-17', + date_to: '2023-01-17', value: '40.325438794298876', is_approximate: false, }, { date: '2024-01-18', + date_to: '2023-01-18', value: '37.55145309930694', is_approximate: false, }, { date: '2024-01-19', + date_to: '2023-01-19', value: '33.271450114434664', is_approximate: false, }, { date: '2024-01-20', + date_to: '2023-01-20', value: '19.303304377685638', is_approximate: false, }, { date: '2024-01-21', + date_to: '2023-01-21', value: '14.375908594704976', is_approximate: false, }, ], + info: { + title: 'Chart title', + description: 'Chart description', + id: 'chart', + resolutions: [ 'DAY', 'MONTH' ], + }, }; diff --git a/mocks/stats/lines.ts b/mocks/stats/lines.ts index 9f8870249f..9dfccb0fdf 100644 --- a/mocks/stats/lines.ts +++ b/mocks/stats/lines.ts @@ -11,18 +11,21 @@ export const base: stats.LineCharts = { title: 'Accounts growth', description: 'Cumulative accounts number per period', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'activeAccounts', title: 'Active accounts', description: 'Active accounts number per period', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'newAccounts', title: 'New accounts', description: 'New accounts number per day', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, ], }, @@ -35,30 +38,35 @@ export const base: stats.LineCharts = { title: 'Average transaction fee', description: 'The average amount in ETH spent per transaction', units: 'ETH', + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'newTxns', title: 'New transactions', description: 'New transactions number', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'txnsFee', title: 'Transactions fees', description: 'Amount of tokens paid as fees', units: 'ETH', + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'txnsGrowth', title: 'Transactions growth', description: 'Cumulative transactions number', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'txnsSuccessRate', title: 'Transactions success rate', description: 'Successful transactions rate per day', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, ], }, @@ -71,18 +79,21 @@ export const base: stats.LineCharts = { title: 'Average block rewards', description: 'Average amount of distributed reward in tokens per day', units: 'ETH', + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'averageBlockSize', title: 'Average block size', description: 'Average size of blocks in bytes', units: 'Bytes', + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'newBlocks', title: 'New blocks', description: 'New blocks number', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, ], }, @@ -95,6 +106,7 @@ export const base: stats.LineCharts = { title: 'New ETH transfers', description: 'New token transfers number for the period', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, ], }, @@ -107,18 +119,21 @@ export const base: stats.LineCharts = { title: 'Average gas limit', description: 'Average gas limit per block for the period', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'averageGasPrice', title: 'Average gas price', description: 'Average gas price for the period (Gwei)', units: 'Gwei', + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'gasUsedGrowth', title: 'Gas used growth', description: 'Cumulative gas used for the period', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, ], }, @@ -131,12 +146,14 @@ export const base: stats.LineCharts = { title: 'New verified contracts', description: 'New verified contracts number for the period', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'verifiedContractsGrowth', title: 'Verified contracts growth', description: 'Cumulative number verified contracts for the period', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, ], }, diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index 2d947f73ec..47205b94e2 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -55,6 +55,7 @@ declare module "nextjs-routes" { | StaticRoute<"/public-tags/submit"> | StaticRoute<"/search-results"> | StaticRoute<"/sprite"> + | DynamicRoute<"/stats/[id]", { "id": string }> | StaticRoute<"/stats"> | DynamicRoute<"/token/[hash]", { "hash": string }> | DynamicRoute<"/token/[hash]/instance/[id]", { "hash": string; "id": string }> diff --git a/nextjs/utils/fetchApi.ts b/nextjs/utils/fetchApi.ts index 63eff42384..5597cf8ede 100644 --- a/nextjs/utils/fetchApi.ts +++ b/nextjs/utils/fetchApi.ts @@ -12,6 +12,7 @@ type Params = ( { resource: R; pathParams?: ResourcePathParams; + queryParams?: Record; } | { url: string; route: string; @@ -22,12 +23,11 @@ type Params = ( export default async function fetchApi>(params: Params): Promise { const controller = new AbortController(); - const timeout = setTimeout(() => { controller.abort(); }, params.timeout || SECOND); - const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams); + const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams, params.queryParams); const route = 'route' in params ? params.route : RESOURCES[params.resource]['path']; const end = metrics?.apiRequestDuration.startTimer(); diff --git a/package.json b/package.json index 7fdedd92f5..ad8d79f546 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@blockscout/bens-types": "1.4.1", - "@blockscout/stats-types": "1.6.0", + "@blockscout/stats-types": "2.0.0", "@blockscout/visualizer-types": "0.2.0", "@chakra-ui/react": "2.7.1", "@chakra-ui/theme-tools": "^2.0.18", diff --git a/pages/stats/[id].tsx b/pages/stats/[id].tsx new file mode 100644 index 0000000000..674ea34f31 --- /dev/null +++ b/pages/stats/[id].tsx @@ -0,0 +1,50 @@ +import type { GetServerSideProps, NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Route } from 'nextjs-routes'; +import * as gSSP from 'nextjs/getServerSideProps'; +import type { Props } from 'nextjs/getServerSideProps'; +import PageNextJs from 'nextjs/PageNextJs'; +import detectBotRequest from 'nextjs/utils/detectBotRequest'; +import fetchApi from 'nextjs/utils/fetchApi'; + +import config from 'configs/app'; +import dayjs from 'lib/date/dayjs'; +import getQueryParamString from 'lib/router/getQueryParamString'; + +const Chart = dynamic(() => import('ui/pages/Chart'), { ssr: false }); + +const pathname: Route['pathname'] = '/stats/[id]'; + +const Page: NextPage> = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export const getServerSideProps: GetServerSideProps> = async(ctx) => { + const baseResponse = await gSSP.base(ctx); + + if ('props' in baseResponse) { + if ( + config.meta.seo.enhancedDataEnabled || + (config.meta.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview') + ) { + const chartData = await fetchApi({ + resource: 'stats_line', + pathParams: { id: getQueryParamString(ctx.query.id) }, + queryParams: { from: dayjs().format('YYYY-MM-DD'), to: dayjs().format('YYYY-MM-DD') }, + timeout: 1000, + }); + + (await baseResponse.props).apiData = chartData?.info ?? null; + } + } + + return baseResponse; +}; diff --git a/stubs/stats.ts b/stubs/stats.ts index e1e70724fb..3e7be4b565 100644 --- a/stubs/stats.ts +++ b/stubs/stats.ts @@ -51,24 +51,28 @@ export const STATS_CHARTS_SECTION: stats.LineChartSection = { title: 'Average transaction fee', description: 'The average amount in ETH spent per transaction', units: 'ETH', + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'chart_1', title: 'Transactions fees', description: 'Amount of tokens paid as fees', units: 'ETH', + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'chart_2', title: 'New transactions', description: 'New transactions number', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, { id: 'chart_3', title: 'Transactions growth', description: 'Cumulative transactions number', units: undefined, + resolutions: [ 'DAY', 'MONTH' ], }, ], }; diff --git a/theme/components/Tag/Tag.ts b/theme/components/Tag/Tag.ts index 6ea4c41d10..70f90a1752 100644 --- a/theme/components/Tag/Tag.ts +++ b/theme/components/Tag/Tag.ts @@ -47,6 +47,16 @@ const sizes = { lineHeight: 5, }, }), + md: definePartsStyle({ + container: { + minH: 8, + minW: 8, + fontSize: 'sm', + px: '6px', + py: '6px', + lineHeight: 5, + }, + }), }; const baseStyleContainer = defineStyle({ diff --git a/ui/address/AddressCoinBalance.pw.tsx b/ui/address/AddressCoinBalance.pw.tsx index 3950798784..97a3789975 100644 --- a/ui/address/AddressCoinBalance.pw.tsx +++ b/ui/address/AddressCoinBalance.pw.tsx @@ -1,7 +1,7 @@ import React from 'react'; import * as balanceHistoryMock from 'mocks/address/coinBalanceHistory'; -import { test, expect } from 'playwright/lib'; +import { test, expect, devices } from 'playwright/lib'; import AddressCoinBalance from './AddressCoinBalance'; @@ -12,7 +12,7 @@ const hooksConfig = { }, }; -test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) => { +test('base view +@dark-mode', async({ render, page, mockApiResponse }) => { await mockApiResponse('address_coin_balance', balanceHistoryMock.baseResponse, { pathParams: { hash: addressHash } }); await mockApiResponse('address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } }); const component = await render(, { hooksConfig }); @@ -23,3 +23,19 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) await page.mouse.move(240, 100); await expect(component).toHaveScreenshot(); }); + +test.describe('mobile', () => { + test.use({ viewport: devices['iPhone 13 Pro'].viewport }); + + test('base view', async({ render, page, mockApiResponse }) => { + await mockApiResponse('address_coin_balance', balanceHistoryMock.baseResponse, { pathParams: { hash: addressHash } }); + await mockApiResponse('address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } }); + const component = await render(, { hooksConfig }); + await page.waitForFunction(() => { + return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1'; + }); + await page.mouse.move(100, 100); + await page.mouse.move(240, 100); + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-1.png new file mode 100644 index 0000000000..582a2e501c Binary files /dev/null and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png deleted file mode 100644 index 4b2d50bcea..0000000000 Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png and /dev/null differ diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-1.png new file mode 100644 index 0000000000..8305b23155 Binary files /dev/null and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png deleted file mode 100644 index 58053b91e2..0000000000 Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png and /dev/null differ diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_mobile-base-view-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_mobile-base-view-1.png new file mode 100644 index 0000000000..f624ea731c Binary files /dev/null and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png index c86acec370..2b1559efce 100644 Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/Chart.pw.tsx b/ui/pages/Chart.pw.tsx new file mode 100644 index 0000000000..d5e494abbb --- /dev/null +++ b/ui/pages/Chart.pw.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import * as statsLineMock from 'mocks/stats/line'; +import { test, expect } from 'playwright/lib'; +import formatDate from 'ui/shared/chart/utils/formatIntervalDate'; + +import Chart from './Chart'; + +const CHART_ID = 'averageGasPrice'; + +test.beforeEach(async({ mockTextAd }) => { + await mockTextAd(); +}); + +const hooksConfig = { + router: { + query: { id: CHART_ID }, + }, +}; + +test('base view +@dark-mode +@mobile', async({ render, mockApiResponse, page }) => { + const date = new Date(); + date.setMonth(date.getMonth() - 1); + + const chartApiUrl = await mockApiResponse( + 'stats_line', + statsLineMock.averageGasPrice, + { + pathParams: { id: CHART_ID }, + queryParams: { + from: formatDate(date), + to: '2022-11-11', + resolution: 'DAY', + }, + }, + ); + + const component = await render(, { hooksConfig }); + await page.waitForResponse(chartApiUrl); + await page.waitForFunction(() => { + return document.querySelector('path[data-name="chart-Charttitle-fullscreen"]')?.getAttribute('opacity') === '1'; + }); + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/pages/Chart.tsx b/ui/pages/Chart.tsx new file mode 100644 index 0000000000..fc5ed7b9b1 --- /dev/null +++ b/ui/pages/Chart.tsx @@ -0,0 +1,259 @@ +import { Button, Flex, Link, Text } from '@chakra-ui/react'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import { Resolution } from '@blockscout/stats-types'; +import type { StatsIntervalIds } from 'types/client/stats'; +import { StatsIntervalId } from 'types/client/stats'; + +import config from 'configs/app'; +import { useAppContext } from 'lib/contexts/app'; +import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import isBrowser from 'lib/isBrowser'; +import * as metadata from 'lib/metadata'; +import * as mixpanel from 'lib/mixpanel/index'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import isCustomAppError from 'ui/shared/AppError/isCustomAppError'; +import ChartIntervalSelect from 'ui/shared/chart/ChartIntervalSelect'; +import ChartMenu from 'ui/shared/chart/ChartMenu'; +import ChartResolutionSelect from 'ui/shared/chart/ChartResolutionSelect'; +import ChartWidgetContent from 'ui/shared/chart/ChartWidgetContent'; +import useChartQuery from 'ui/shared/chart/useChartQuery'; +import useZoom from 'ui/shared/chart/useZoom'; +import CopyToClipboard from 'ui/shared/CopyToClipboard'; +import IconSvg from 'ui/shared/IconSvg'; +import PageTitle from 'ui/shared/Page/PageTitle'; + +const DEFAULT_RESOLUTION = Resolution.DAY; + +const getIntervalByResolution = (resolution: Resolution): StatsIntervalIds => { + switch (resolution) { + case 'DAY': + return 'oneMonth'; + case 'WEEK': + return 'oneMonth'; + case 'MONTH': + return 'oneYear'; + case 'YEAR': + return 'all'; + default: + return 'oneMonth'; + } +}; + +const getIntervalFromQuery = (router: NextRouter): StatsIntervalIds | undefined => { + const intervalFromQuery = getQueryParamString(router.query.interval); + + if (!intervalFromQuery || !Object.values(StatsIntervalId).includes(intervalFromQuery as StatsIntervalIds)) { + return undefined; + } + + return intervalFromQuery as StatsIntervalIds; +}; + +const getResolutionFromQuery = (router: NextRouter) => { + const resolutionFromQuery = getQueryParamString(router.query.resolution); + + if (!resolutionFromQuery || !Resolution[resolutionFromQuery as keyof typeof Resolution]) { + return DEFAULT_RESOLUTION; + } + + return resolutionFromQuery as Resolution; +}; + +const Chart = () => { + const router = useRouter(); + const id = getQueryParamString(router.query.id); + const intervalFromQuery = getIntervalFromQuery(router); + const resolutionFromQuery = getResolutionFromQuery(router); + const [ intervalState, setIntervalState ] = React.useState(intervalFromQuery); + const [ resolution, setResolution ] = React.useState(resolutionFromQuery || DEFAULT_RESOLUTION); + const { zoomRange, handleZoom, handleZoomReset } = useZoom(); + + const interval = intervalState || getIntervalByResolution(resolution); + + const ref = React.useRef(null); + + const isMobile = useIsMobile(); + const isInBrowser = isBrowser(); + + const appProps = useAppContext(); + const backLink = React.useMemo(() => { + const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/stats'); + + if (!hasGoBackLink) { + return; + } + + return { + label: 'Back to charts list', + url: appProps.referrer, + }; + }, [ appProps.referrer ]); + + const onIntervalChange = React.useCallback((interval: StatsIntervalIds) => { + setIntervalState(interval); + router.push( + { + pathname: router.pathname, + query: { ...router.query, interval }, + }, + undefined, + { shallow: true }, + ); + }, [ setIntervalState, router ]); + + const onResolutionChange = React.useCallback((resolution: Resolution) => { + setResolution(resolution); + router.push({ + pathname: router.pathname, + query: { ...router.query, resolution }, + }); + }, [ setResolution, router ]); + + const handleReset = React.useCallback(() => { + handleZoomReset(); + onResolutionChange(DEFAULT_RESOLUTION); + }, [ handleZoomReset, onResolutionChange ]); + + const { items, info, lineQuery } = useChartQuery(id, resolution, interval); + + React.useEffect(() => { + if (info && !config.meta.seo.enhancedDataEnabled) { + metadata.update({ pathname: '/stats/[id]', query: { id } }, info); + } + }, [ info, id ]); + + const onShare = React.useCallback(async() => { + mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Share chart', Info: id }); + try { + await window.navigator.share({ + title: info?.title, + text: info?.description, + url: window.location.href, + }); + } catch (error) {} + }, [ info, id ]); + + if (lineQuery.isError) { + if (isCustomAppError(lineQuery.error)) { + throwOnResourceLoadError({ resource: 'stats_line', error: lineQuery.error, isError: true }); + } + } + + const hasItems = (items && items.length > 2) || lineQuery.isPending; + + const isInfoLoading = !info && lineQuery.isPlaceholderData; + + const shareButton = ( + + ); + + return ( + <> + + + + + { !isMobile && Period } + + + { lineQuery.data?.info?.resolutions && lineQuery.data?.info?.resolutions.length > 1 && ( + + { isMobile ? 'Res.' : 'Resolution' } + + + ) } + { (Boolean(zoomRange)) && ( + + + { !isMobile && 'Reset' } + + ) } + + + { /* TS thinks window.navigator.share can't be undefined, but it can */ } + { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ } + { !isMobile && (isInBrowser && ((window.navigator.share as any) ? + shareButton : + ( + + ) + )) } + { (hasItems || lineQuery.isPlaceholderData) && ( + + ) } + + + + + + + ); +}; + +export default Chart; diff --git a/ui/pages/__screenshots__/Chart.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/Chart.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png new file mode 100644 index 0000000000..941d24b6ff Binary files /dev/null and b/ui/pages/__screenshots__/Chart.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Chart.pw.tsx_default_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/Chart.pw.tsx_default_base-view-dark-mode-mobile-1.png new file mode 100644 index 0000000000..6c36ef1fb6 Binary files /dev/null and b/ui/pages/__screenshots__/Chart.pw.tsx_default_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Chart.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/Chart.pw.tsx_mobile_base-view-dark-mode-mobile-1.png new file mode 100644 index 0000000000..d5d08c144c Binary files /dev/null and b/ui/pages/__screenshots__/Chart.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/GasTracker.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/GasTracker.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png index 6f9eb34dd0..90d7a7fb43 100644 Binary files a/ui/pages/__screenshots__/GasTracker.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/GasTracker.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-1.png b/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-1.png deleted file mode 100644 index 41e55e459c..0000000000 Binary files a/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-1.png and /dev/null differ diff --git a/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-dark-mode-mobile-1.png index f906d3bc32..e66ab8f990 100644 Binary files a/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/GasTracker.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/GasTracker.pw.tsx_mobile_base-view-dark-mode-mobile-1.png index f0a0b7eb28..2244c7e759 100644 Binary files a/ui/pages/__screenshots__/GasTracker.pw.tsx_mobile_base-view-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/GasTracker.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ diff --git a/ui/shared/CopyToClipboard.tsx b/ui/shared/CopyToClipboard.tsx index c17a16092e..1a0daa9d7c 100644 --- a/ui/shared/CopyToClipboard.tsx +++ b/ui/shared/CopyToClipboard.tsx @@ -22,6 +22,7 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type, // have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107 const { isOpen, onOpen, onClose } = useDisclosure(); const iconColor = useColorModeValue('gray.400', 'gray.500'); + const colorProps = colorScheme ? {} : { color: iconColor }; const iconName = icon || (type === 'link' ? 'link' : 'copy'); useEffect(() => { @@ -44,10 +45,10 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type, return ( } boxSize={ size } - color={ iconColor } variant={ variant } colorScheme={ colorScheme } display="inline-block" diff --git a/ui/shared/Page/PageTitle.tsx b/ui/shared/Page/PageTitle.tsx index e406b098eb..844f301498 100644 --- a/ui/shared/Page/PageTitle.tsx +++ b/ui/shared/Page/PageTitle.tsx @@ -154,9 +154,9 @@ const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoa { withTextAd && } { secondRow && ( - + { secondRow } - + ) } ); diff --git a/ui/shared/chart/ChartIntervalSelect.tsx b/ui/shared/chart/ChartIntervalSelect.tsx new file mode 100644 index 0000000000..6e74919f50 --- /dev/null +++ b/ui/shared/chart/ChartIntervalSelect.tsx @@ -0,0 +1,45 @@ +import type { TagProps } from '@chakra-ui/react'; +import { Skeleton } from '@chakra-ui/react'; +import React from 'react'; + +import type { StatsInterval, StatsIntervalIds } from 'types/client/stats'; + +import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect'; +import { STATS_INTERVALS } from 'ui/stats/constants'; +import StatsDropdownMenu from 'ui/stats/StatsDropdownMenu'; + +const intervalList = Object.keys(STATS_INTERVALS).map((id: string) => ({ + id: id, + title: STATS_INTERVALS[id as StatsIntervalIds].title, +})) as Array; + +const intervalListShort = Object.keys(STATS_INTERVALS).map((id: string) => ({ + id: id, + title: STATS_INTERVALS[id as StatsIntervalIds].shortTitle, +})) as Array; + +type Props = { + interval: StatsIntervalIds; + onIntervalChange: (newInterval: StatsIntervalIds) => void; + isLoading?: boolean; + selectTagSize?: TagProps['size']; +} + +const ChartIntervalSelect = ({ interval, onIntervalChange, isLoading, selectTagSize }: Props) => { + return ( + <> + + items={ intervalListShort } onChange={ onIntervalChange } value={ interval } tagSize={ selectTagSize }/> + + + + + + ); +}; + +export default React.memo(ChartIntervalSelect); diff --git a/ui/shared/chart/ChartMenu.tsx b/ui/shared/chart/ChartMenu.tsx new file mode 100644 index 0000000000..19877061b5 --- /dev/null +++ b/ui/shared/chart/ChartMenu.tsx @@ -0,0 +1,197 @@ +import { + IconButton, + MenuButton, + MenuItem, + MenuList, + Skeleton, + useClipboard, + useColorModeValue, + VisuallyHidden, +} from '@chakra-ui/react'; +import domToImage from 'dom-to-image'; +import React from 'react'; + +import type { TimeChartItem } from './types'; +import type { Resolution } from '@blockscout/stats-types'; + +import dayjs from 'lib/date/dayjs'; +import isBrowser from 'lib/isBrowser'; +import saveAsCSV from 'lib/saveAsCSV'; +import Menu from 'ui/shared/chakra/Menu'; +import IconSvg from 'ui/shared/IconSvg'; + +import FullscreenChartModal from './FullscreenChartModal'; + +export type Props = { + items?: Array; + title: string; + description?: string; + units?: string; + isLoading: boolean; + chartRef: React.RefObject; + chartUrl?: string; + resolution?: Resolution; + zoomRange?: [ Date, Date ]; + handleZoom: (range: [ Date, Date ]) => void; + handleZoomReset: () => void; +} + +const DOWNLOAD_IMAGE_SCALE = 5; + +const ChartMenu = ({ + items, + title, + description, + units, + isLoading, + chartRef, + chartUrl, + resolution, + zoomRange, + handleZoom, + handleZoomReset, +}: Props) => { + const pngBackgroundColor = useColorModeValue('white', 'black'); + const [ isFullscreen, setIsFullscreen ] = React.useState(false); + + const { onCopy } = useClipboard(chartUrl ?? ''); + + const isInBrowser = isBrowser(); + + const showChartFullscreen = React.useCallback(() => { + setIsFullscreen(true); + }, []); + + const clearFullscreenChart = React.useCallback(() => { + setIsFullscreen(false); + }, []); + + const handleFileSaveClick = React.useCallback(() => { + // wait for context menu to close + setTimeout(() => { + if (chartRef.current) { + domToImage.toPng(chartRef.current, + { + quality: 100, + bgcolor: pngBackgroundColor, + width: chartRef.current.offsetWidth * DOWNLOAD_IMAGE_SCALE, + height: chartRef.current.offsetHeight * DOWNLOAD_IMAGE_SCALE, + filter: (node) => node.nodeName !== 'BUTTON', + style: { + borderColor: 'transparent', + transform: `scale(${ DOWNLOAD_IMAGE_SCALE })`, + 'transform-origin': 'top left', + }, + }) + .then((dataUrl) => { + const link = document.createElement('a'); + link.download = `${ title } (Blockscout chart).png`; + link.href = dataUrl; + link.click(); + link.remove(); + }); + } + }, 100); + }, [ pngBackgroundColor, title, chartRef ]); + + const handleSVGSavingClick = React.useCallback(() => { + if (items) { + const headerRows = [ + 'Date', 'Value', + ]; + const dataRows = items.map((item) => [ + dayjs(item.date).format('YYYY-MM-DD'), String(item.value), + ]); + + saveAsCSV(headerRows, dataRows, `${ title } (Blockscout stats)`); + } + }, [ items, title ]); + + // TS thinks window.navigator.share can't be undefined, but it can + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hasShare = isInBrowser && (window.navigator.share as any); + + const handleShare = React.useCallback(async() => { + try { + await window.navigator.share({ + title: title, + text: description, + url: chartUrl, + }); + } catch (error) {} + }, [ title, description, chartUrl ]); + + return ( + <> + + + } + colorScheme="gray" + variant="simple" + as={ IconButton } + > + + Open chart options menu + + + + + { chartUrl && ( + + + { hasShare ? 'Share' : 'Copy link' } + + ) } + + + View fullscreen + + + + Save as PNG + + + + Save as CSV + + + + { items && isFullscreen && ( + + ) } + + ); +}; + +export default ChartMenu; diff --git a/ui/shared/chart/ChartResolutionSelect.tsx b/ui/shared/chart/ChartResolutionSelect.tsx new file mode 100644 index 0000000000..999ecf0136 --- /dev/null +++ b/ui/shared/chart/ChartResolutionSelect.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from '@chakra-ui/react'; +import React from 'react'; + +import type { Resolution } from '@blockscout/stats-types'; + +import { STATS_RESOLUTIONS } from 'ui/stats/constants'; +import StatsDropdownMenu from 'ui/stats/StatsDropdownMenu'; + +type Props = { + resolution: Resolution; + resolutions: Array; + onResolutionChange: (resolution: Resolution) => void; + isLoading?: boolean; +} + +const ChartResolutionSelect = ({ resolution, resolutions, onResolutionChange, isLoading }: Props) => { + return ( + + resolutions.includes(r.id)) } + selectedId={ resolution } + onSelect={ onResolutionChange } + /> + + ); +}; + +export default React.memo(ChartResolutionSelect); diff --git a/ui/shared/chart/ChartTooltip.tsx b/ui/shared/chart/ChartTooltip.tsx index 71e51ab738..611e58cb9f 100644 --- a/ui/shared/chart/ChartTooltip.tsx +++ b/ui/shared/chart/ChartTooltip.tsx @@ -1,6 +1,7 @@ import * as d3 from 'd3'; import React from 'react'; +import { Resolution } from '@blockscout/stats-types'; import type { TimeChartData } from 'ui/shared/chart/types'; import ChartTooltipBackdrop, { useRenderBackdrop } from './tooltip/ChartTooltipBackdrop'; @@ -21,9 +22,21 @@ interface Props { yScale: d3.ScaleLinear; anchorEl: SVGRectElement | null; noAnimation?: boolean; + resolution?: Resolution; } -const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, anchorEl, noAnimation, ...props }: Props) => { +const ChartTooltip = ({ + xScale, + yScale, + width, + tooltipWidth = 200, + height, + data, + anchorEl, + noAnimation, + resolution, + ...props +}: Props) => { const ref = React.useRef(null); const trackerId = React.useRef(); const isVisible = React.useRef(false); @@ -150,8 +163,8 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, { data.map(({ name }) => ) } - - + + { data.map(({ name }, index) => ) } @@ -159,3 +172,16 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, }; export default React.memo(ChartTooltip); + +function getDateLabel(resolution?: Resolution): string { + switch (resolution) { + case Resolution.WEEK: + return 'Dates'; + case Resolution.MONTH: + return 'Month'; + case Resolution.YEAR: + return 'Year'; + default: + return 'Date'; + } +} diff --git a/ui/shared/chart/ChartWatermarkIcon.tsx b/ui/shared/chart/ChartWatermarkIcon.tsx new file mode 100644 index 0000000000..347740a27f --- /dev/null +++ b/ui/shared/chart/ChartWatermarkIcon.tsx @@ -0,0 +1,26 @@ +import type { IconProps } from '@chakra-ui/react'; +import { Icon, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; + +// eslint-disable-next-line no-restricted-imports +import logoIcon from 'icons/networks/logo-placeholder.svg'; + +const ChartWatermarkIcon = (props: IconProps) => { + const watermarkColor = useColorModeValue('link', 'white'); + return ( + + ); +}; + +export default ChartWatermarkIcon; diff --git a/ui/shared/chart/ChartWidget.tsx b/ui/shared/chart/ChartWidget.tsx index e32a1e29ae..fb87a690bf 100644 --- a/ui/shared/chart/ChartWidget.tsx +++ b/ui/shared/chart/ChartWidget.tsx @@ -1,31 +1,24 @@ import { - Box, - Center, chakra, Flex, - IconButton, Link, - MenuButton, - MenuItem, - MenuList, + IconButton, Skeleton, - Text, Tooltip, useColorModeValue, - VisuallyHidden, } from '@chakra-ui/react'; -import domToImage from 'dom-to-image'; -import React, { useRef, useCallback, useState } from 'react'; +import NextLink from 'next/link'; +import React, { useRef } from 'react'; import type { TimeChartItem } from './types'; -import dayjs from 'lib/date/dayjs'; -import { apos } from 'lib/html-entities'; -import saveAsCSV from 'lib/saveAsCSV'; -import Menu from 'ui/shared/chakra/Menu'; +import { route, type Route } from 'nextjs-routes'; + +import config from 'configs/app'; import IconSvg from 'ui/shared/IconSvg'; -import ChartWidgetGraph from './ChartWidgetGraph'; -import FullscreenChartModal from './FullscreenChartModal'; +import ChartMenu from './ChartMenu'; +import ChartWidgetContent from './ChartWidgetContent'; +import useZoom from './useZoom'; export type Props = { items?: Array; @@ -37,236 +30,124 @@ export type Props = { isError: boolean; emptyText?: string; noAnimation?: boolean; + href?: Route; } -const DOWNLOAD_IMAGE_SCALE = 5; - -const ChartWidget = ({ items, title, description, isLoading, className, isError, units, emptyText, noAnimation }: Props) => { +const ChartWidget = ({ + items, + title, + description, + isLoading, + className, + isError, + units, + emptyText, + noAnimation, + href, +}: Props) => { const ref = useRef(null); - const [ isFullscreen, setIsFullscreen ] = useState(false); - const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); + const { zoomRange, handleZoom, handleZoomReset } = useZoom(); - const pngBackgroundColor = useColorModeValue('white', 'black'); const borderColor = useColorModeValue('gray.200', 'gray.600'); - const handleZoom = useCallback(() => { - setIsZoomResetInitial(false); - }, []); - - const handleZoomResetClick = useCallback(() => { - setIsZoomResetInitial(true); - }, []); - - const showChartFullscreen = useCallback(() => { - setIsFullscreen(true); - }, []); - - const clearFullscreenChart = useCallback(() => { - setIsFullscreen(false); - }, []); - - const handleFileSaveClick = useCallback(() => { - // wait for context menu to close - setTimeout(() => { - if (ref.current) { - domToImage.toPng(ref.current, - { - quality: 100, - bgcolor: pngBackgroundColor, - width: ref.current.offsetWidth * DOWNLOAD_IMAGE_SCALE, - height: ref.current.offsetHeight * DOWNLOAD_IMAGE_SCALE, - filter: (node) => node.nodeName !== 'BUTTON', - style: { - borderColor: 'transparent', - transform: `scale(${ DOWNLOAD_IMAGE_SCALE })`, - 'transform-origin': 'top left', - }, - }) - .then((dataUrl) => { - const link = document.createElement('a'); - link.download = `${ title } (Blockscout chart).png`; - link.href = dataUrl; - link.click(); - link.remove(); - }); - } - }, 100); - }, [ pngBackgroundColor, title ]); - - const handleSVGSavingClick = useCallback(() => { - if (items) { - const headerRows = [ - 'Date', 'Value', - ]; - const dataRows = items.map((item) => [ - dayjs(item.date).format('YYYY-MM-DD'), String(item.value), - ]); - - saveAsCSV(headerRows, dataRows, `${ title } (Blockscout stats)`); - } - }, [ items, title ]); - const hasItems = items && items.length > 2; - const content = (() => { - if (isError) { - return ( - - - { `The data didn${ apos }t load. Please, ` } - try to reload the page. - - - ); - } - - if (isLoading) { - return ; - } - - if (!hasItems) { - return ( -
- { emptyText || 'No data' } -
- ); - } - - return ( - - - - ); - })(); + const content = ( + + ); - return ( - <> - + - - - - { title } - - - { description && ( - - { description } - - ) } - - - - + { title } + - { hasItems && ( - - - } - colorScheme="gray" - variant="ghost" - as={ IconButton } - > - - Open chart options menu - - - - - - - View fullscreen - + { description && ( + - - Save as PNG - + > + { description } + + ) } + + ); - - - Save as CSV - - - - ) } - + return ( + + + { href ? ( + + { chartHeader } + + ) : chartHeader } + + + + + { hasItems && ( + + ) } - - { content } - { hasItems && ( - - ) } - + { content } + ); }; diff --git a/ui/shared/chart/ChartWidgetContent.tsx b/ui/shared/chart/ChartWidgetContent.tsx new file mode 100644 index 0000000000..0180ee57e7 --- /dev/null +++ b/ui/shared/chart/ChartWidgetContent.tsx @@ -0,0 +1,90 @@ +import { Box, Center, Flex, Link, Skeleton, Text } from '@chakra-ui/react'; +import React from 'react'; + +import type { TimeChartItem } from './types'; +import type { Resolution } from '@blockscout/stats-types'; + +import { apos } from 'lib/html-entities'; + +import ChartWatermarkIcon from './ChartWatermarkIcon'; +import ChartWidgetGraph from './ChartWidgetGraph'; + +export type Props = { + items?: Array; + title: string; + units?: string; + isLoading?: boolean; + isError?: boolean; + emptyText?: string; + zoomRange?: [ Date, Date ]; + handleZoom: (range: [ Date, Date ]) => void; + isEnlarged?: boolean; + noAnimation?: boolean; + resolution?: Resolution; +} + +const ChartWidgetContent = ({ + items, + title, + isLoading, + isError, + units, + emptyText, + zoomRange, + handleZoom, + isEnlarged, + noAnimation, + resolution, +}: Props) => { + const hasItems = items && items.length > 2; + + if (isError) { + return ( + + + { `The data didn${ apos }t load. Please, ` } + try to reload the page. + + + ); + } + + if (isLoading) { + return ; + } + + if (!hasItems) { + return ( +
+ { emptyText || 'No data' } +
+ ); + } + + return ( + + + + + ); +}; + +export default React.memo(ChartWidgetContent); diff --git a/ui/shared/chart/ChartWidgetGraph.tsx b/ui/shared/chart/ChartWidgetGraph.tsx index ce1ee8e4c9..04071252d3 100644 --- a/ui/shared/chart/ChartWidgetGraph.tsx +++ b/ui/shared/chart/ChartWidgetGraph.tsx @@ -2,9 +2,9 @@ import { useToken } from '@chakra-ui/react'; import * as d3 from 'd3'; import React from 'react'; +import { Resolution } from '@blockscout/stats-types'; import type { ChartMargin, TimeChartData, TimeChartItem } from 'ui/shared/chart/types'; -import dayjs from 'lib/date/dayjs'; import useIsMobile from 'lib/hooks/useIsMobile'; import ChartArea from 'ui/shared/chart/ChartArea'; import ChartAxis from 'ui/shared/chart/ChartAxis'; @@ -20,37 +20,42 @@ interface Props { title: string; units?: string; items: Array; - onZoom: () => void; - isZoomResetInitial: boolean; + zoomRange?: [ Date, Date ]; + onZoom: (range: [ Date, Date ]) => void; margin?: ChartMargin; noAnimation?: boolean; + resolution?: Resolution; } -// temporarily turn off the data aggregation, we need a better algorithm for that -const MAX_SHOW_ITEMS = 100_000_000_000; const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 }; -const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin: marginProps, units, noAnimation }: Props) => { +const ChartWidgetGraph = ({ + isEnlarged, + items, + onZoom, + title, + margin: marginProps, + units, + noAnimation, + resolution, + zoomRange, +}: Props) => { const isMobile = useIsMobile(); const color = useToken('colors', 'blue.200'); const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`; const overlayRef = React.useRef(null); - const [ range, setRange ] = React.useState<[ Date, Date ]>([ items[0].date, items[items.length - 1].date ]); + const range = React.useMemo(() => zoomRange || [ items[0].date, items[items.length - 1].date ], [ zoomRange, items ]); - const rangedItems = React.useMemo(() => - items.filter((item) => item.date >= range[0] && item.date <= range[1]), - [ items, range ]); - const isGroupedValues = rangedItems.length > MAX_SHOW_ITEMS; - - const displayedData = React.useMemo(() => { - if (isGroupedValues) { - return groupChartItemsByWeekNumber(rangedItems); - } else { - return rangedItems; - } - }, [ isGroupedValues, rangedItems ]); + const displayedData = React.useMemo(() => + items + .filter((item) => item.date >= range[0] && item.date <= range[1]) + .map((item) => ({ + ...item, + dateLabel: getDateLabel(item.date, item.date_to, resolution), + })), + [ items, range, resolution ]); const chartData: TimeChartData = React.useMemo(() => ([ { items: displayedData, name: 'Value', color, units } ]), [ color, displayedData, units ]); @@ -80,17 +85,6 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title axesConfig, }); - const handleRangeSelect = React.useCallback((nextRange: [ Date, Date ]) => { - setRange([ nextRange[0], nextRange[1] ]); - onZoom(); - }, [ onZoom ]); - - React.useEffect(() => { - if (isZoomResetInitial) { - setRange([ items[0].date, items[items.length - 1].date ]); - } - }, [ isZoomResetInitial, items ]); - return ( @@ -143,12 +137,13 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title @@ -166,13 +161,15 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title export default React.memo(ChartWidgetGraph); -function groupChartItemsByWeekNumber(items: Array): Array { - return d3.rollups(items, - (group) => ({ - date: group[0].date, - value: d3.sum(group, (d) => d.value), - dateLabel: `${ d3.timeFormat('%e %b %Y')(group[0].date) } – ${ d3.timeFormat('%e %b %Y')(group[group.length - 1].date) }`, - }), - (t) => `${ dayjs(t.date).week() } / ${ dayjs(t.date).year() }`, - ).map(([ , v ]) => v); +function getDateLabel(date: Date, dateTo?: Date, resolution?: Resolution): string { + switch (resolution) { + case Resolution.WEEK: + return d3.timeFormat('%e %b %Y')(date) + (dateTo ? ` – ${ d3.timeFormat('%e %b %Y')(dateTo) }` : ''); + case Resolution.MONTH: + return d3.timeFormat('%b %Y')(date); + case Resolution.YEAR: + return d3.timeFormat('%Y')(date); + default: + return d3.timeFormat('%e %b %Y')(date); + } } diff --git a/ui/shared/chart/FullscreenChartModal.tsx b/ui/shared/chart/FullscreenChartModal.tsx index 6835474851..476f3086dc 100644 --- a/ui/shared/chart/FullscreenChartModal.tsx +++ b/ui/shared/chart/FullscreenChartModal.tsx @@ -1,11 +1,12 @@ import { Box, Button, Grid, Heading, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay, Text } from '@chakra-ui/react'; -import React, { useCallback } from 'react'; +import React from 'react'; import type { TimeChartItem } from './types'; +import type { Resolution } from '@blockscout/stats-types'; import IconSvg from 'ui/shared/IconSvg'; -import ChartWidgetGraph from './ChartWidgetGraph'; +import ChartWidgetContent from './ChartWidgetContent'; type Props = { isOpen: boolean; @@ -14,6 +15,10 @@ type Props = { items: Array; onClose: () => void; units?: string; + resolution?: Resolution; + zoomRange?: [ Date, Date ]; + handleZoom: (range: [ Date, Date ]) => void; + handleZoomReset: () => void; } const FullscreenChartModal = ({ @@ -23,17 +28,11 @@ const FullscreenChartModal = ({ items, units, onClose, + resolution, + zoomRange, + handleZoom, + handleZoomReset, }: Props) => { - const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); - - const handleZoom = useCallback(() => { - setIsZoomResetInitial(false); - }, []); - - const handleZoomResetClick = useCallback(() => { - setIsZoomResetInitial(true); - }, []); - return ( ) } - { !isZoomResetInitial && ( + { Boolean(zoomRange) && ( } colorScheme="blue" @@ -79,7 +78,7 @@ const FullscreenChartModal = ({ gridRow="1/3" size="sm" variant="outline" - onClick={ handleZoomResetClick } + onClick={ handleZoomReset } > Reset zoom @@ -91,15 +90,16 @@ const FullscreenChartModal = ({ - diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-1.png index 013a49d77f..f6ca5a9b5b 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-2.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-2.png index a96f29fff0..98e4c6d510 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-2.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-2.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-3.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-3.png index e33f6ba409..e93438c41c 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-3.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-3.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-4.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-4.png index f393e68904..7e5ea0df53 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-4.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_dark-color-mode_base-view-dark-mode-4.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-1.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-1.png index ecc5aaa91a..c66c18ae5f 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-1.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-2.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-2.png index 02cd055ba5..4b42410f10 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-2.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-2.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-3.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-3.png index f7d676aa6a..afabf22d29 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-3.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-3.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-4.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-4.png index df1055b271..8169d24768 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-4.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_base-view-dark-mode-4.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_error-1.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_error-1.png index e054536499..03ec8c11ff 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_error-1.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_error-1.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_incomplete-day-1.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_incomplete-day-1.png index b031715818..eb0a751f03 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_incomplete-day-1.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_incomplete-day-1.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_incomplete-day-2.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_incomplete-day-2.png index 073dfce3a1..3f2fa2ed7c 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_incomplete-day-2.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_incomplete-day-2.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_loading-1.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_loading-1.png index 4646071b6b..f502de8f6c 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_loading-1.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_loading-1.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_small-values-1.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_small-values-1.png index 90244d1fdf..6d6db77598 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_small-values-1.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_small-values-1.png differ diff --git a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_small-variations-in-big-values-1.png b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_small-variations-in-big-values-1.png index a522c08ed7..079b107539 100644 Binary files a/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_small-variations-in-big-values-1.png and b/ui/shared/chart/__screenshots__/ChartWidget.pw.tsx_default_small-variations-in-big-values-1.png differ diff --git a/ui/shared/chart/tooltip/ChartTooltipTitle.tsx b/ui/shared/chart/tooltip/ChartTooltipTitle.tsx index 93ab2e9943..30f6025942 100644 --- a/ui/shared/chart/tooltip/ChartTooltipTitle.tsx +++ b/ui/shared/chart/tooltip/ChartTooltipTitle.tsx @@ -2,10 +2,15 @@ import { useToken } from '@chakra-ui/react'; import * as d3 from 'd3'; import React from 'react'; +import { Resolution } from '@blockscout/stats-types'; + +import { STATS_RESOLUTIONS } from 'ui/stats/constants'; + import ChartTooltipRow from './ChartTooltipRow'; -const ChartTooltipTitle = () => { +const ChartTooltipTitle = ({ resolution = Resolution.DAY }: { resolution?: Resolution }) => { const titleColor = useToken('colors', 'yellow.300'); + const resolutionTitle = STATS_RESOLUTIONS.find(r => r.id === resolution)?.title || 'day'; return ( @@ -16,7 +21,7 @@ const ChartTooltipTitle = () => { opacity={ 0 } dominantBaseline="hanging" > - Incomplete day + { `Incomplete ${ resolutionTitle.toLowerCase() }` } ); diff --git a/ui/shared/chart/types.tsx b/ui/shared/chart/types.tsx index c1c02b1b12..655be842a5 100644 --- a/ui/shared/chart/types.tsx +++ b/ui/shared/chart/types.tsx @@ -6,6 +6,7 @@ export interface TimeChartItemRaw { export interface TimeChartItem { date: Date; + date_to?: Date; dateLabel?: string; value: number; isApproximate?: boolean; diff --git a/ui/shared/chart/useChartQuery.tsx b/ui/shared/chart/useChartQuery.tsx new file mode 100644 index 0000000000..7da616d310 --- /dev/null +++ b/ui/shared/chart/useChartQuery.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import type { LineChart, Resolution } from '@blockscout/stats-types'; +import type { StatsIntervalIds } from 'types/client/stats'; + +import useApiQuery from 'lib/api/useApiQuery'; +import { useAppContext } from 'lib/contexts/app'; +import { STATS_INTERVALS } from 'ui/stats/constants'; + +import formatDate from './utils/formatIntervalDate'; + +export default function useChartQuery(id: string, resolution: Resolution, interval: StatsIntervalIds, enabled = true) { + const { apiData } = useAppContext<'/stats/[id]'>(); + + const selectedInterval = STATS_INTERVALS[interval]; + + const endDate = selectedInterval.start ? formatDate(new Date()) : undefined; + const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined; + + const [ info, setInfo ] = React.useState(apiData || undefined); + + const lineQuery = useApiQuery('stats_line', { + pathParams: { id }, + queryParams: { + from: startDate, + to: endDate, + resolution, + }, + queryOptions: { + enabled: enabled, + refetchOnMount: false, + placeholderData: { + info: { + title: 'Chart title placeholder', + description: 'Chart placeholder description chart placeholder description', + resolutions: [ 'DAY', 'WEEK', 'MONTH', 'YEAR' ], + id: 'placeholder', + units: undefined, + }, + chart: [], + }, + }, + }); + + React.useEffect(() => { + if (!info && lineQuery.data?.info && !lineQuery.isPlaceholderData) { + // save info to keep title and description when change query params + setInfo(lineQuery.data?.info); + } + }, [ info, lineQuery.data?.info, lineQuery.isPlaceholderData ]); + + const items = React.useMemo(() => lineQuery.data?.chart?.map((item) => { + return { date: new Date(item.date), date_to: new Date(item.date_to), value: Number(item.value), isApproximate: item.is_approximate }; + }), [ lineQuery ]); + + return { + items, + info, + lineQuery, + }; +} diff --git a/ui/shared/chart/useZoom.tsx b/ui/shared/chart/useZoom.tsx new file mode 100644 index 0000000000..0baa1613f8 --- /dev/null +++ b/ui/shared/chart/useZoom.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export default function useZoom() { + const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); + const [ zoomRange, setZoomRange ] = React.useState<[ Date, Date ] | undefined>(); + + const handleZoom = React.useCallback((range: [ Date, Date ]) => { + setZoomRange(range); + setIsZoomResetInitial(false); + }, []); + + const handleZoomReset = React.useCallback(() => { + setZoomRange(undefined); + setIsZoomResetInitial(true); + }, []); + + return { + isZoomResetInitial, + zoomRange, + handleZoom, + handleZoomReset, + }; +} diff --git a/ui/shared/chart/utils/formatIntervalDate.ts b/ui/shared/chart/utils/formatIntervalDate.ts new file mode 100644 index 0000000000..701fa9a829 --- /dev/null +++ b/ui/shared/chart/utils/formatIntervalDate.ts @@ -0,0 +1,3 @@ +export default function formatDate(date: Date) { + return date.toISOString().substring(0, 10); +} diff --git a/ui/shared/tagGroupSelect/TagGroupSelect.tsx b/ui/shared/tagGroupSelect/TagGroupSelect.tsx index 4bf53b2ce1..3722815382 100644 --- a/ui/shared/tagGroupSelect/TagGroupSelect.tsx +++ b/ui/shared/tagGroupSelect/TagGroupSelect.tsx @@ -1,8 +1,10 @@ +import type { TagProps } from '@chakra-ui/react'; import { HStack, Tag } from '@chakra-ui/react'; import React from 'react'; type Props = { items: Array<{ id: T; title: string }>; + tagSize?: TagProps['size']; } & ( { value: T; @@ -15,7 +17,7 @@ type Props = { } ) -const TagGroupSelect = ({ items, value, isMulti, onChange }: Props) => { +const TagGroupSelect = ({ items, value, isMulti, onChange, tagSize }: Props) => { const onItemClick = React.useCallback((event: React.SyntheticEvent) => { const itemValue = (event.currentTarget as HTMLDivElement).getAttribute('data-id') as T; if (isMulti) { @@ -44,6 +46,9 @@ const TagGroupSelect = ({ items, value, isMulti, onChange }: P fontWeight={ 500 } cursor="pointer" onClick={ onItemClick } + size={ tagSize } + display="inline-flex" + justifyContent="center" > { item.title } diff --git a/ui/stats/ChartWidgetContainer.tsx b/ui/stats/ChartWidgetContainer.tsx index 5f41a5c2af..26b4fc8154 100644 --- a/ui/stats/ChartWidgetContainer.tsx +++ b/ui/stats/ChartWidgetContainer.tsx @@ -1,12 +1,14 @@ import { chakra } from '@chakra-ui/react'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; +import { Resolution } from '@blockscout/stats-types'; import type { StatsIntervalIds } from 'types/client/stats'; -import useApiQuery from 'lib/api/useApiQuery'; +import type { Route } from 'nextjs-routes'; + +import useChartQuery from 'ui/shared/chart/useChartQuery'; import ChartWidget from '../shared/chart/ChartWidget'; -import { STATS_INTERVALS } from './constants'; type Props = { id: string; @@ -17,50 +19,39 @@ type Props = { onLoadingError: () => void; isPlaceholderData: boolean; className?: string; + href?: Route; } -function formatDate(date: Date) { - return date.toISOString().substring(0, 10); -} - -const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError, units, isPlaceholderData, className }: Props) => { - const selectedInterval = STATS_INTERVALS[interval]; - - const endDate = selectedInterval.start ? formatDate(new Date()) : undefined; - const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined; - - const { data, isPending, isError } = useApiQuery('stats_line', { - pathParams: { id }, - queryParams: { - from: startDate, - to: endDate, - }, - queryOptions: { - enabled: !isPlaceholderData, - refetchOnMount: false, - }, - }); - - const items = useMemo(() => data?.chart?.map((item) => { - return { date: new Date(item.date), value: Number(item.value), isApproximate: item.is_approximate }; - }), [ data ]); +const ChartWidgetContainer = ({ + id, + title, + description, + interval, + onLoadingError, + units, + isPlaceholderData, + className, + href, +}: Props) => { + const { items, lineQuery } = useChartQuery(id, Resolution.DAY, interval, !isPlaceholderData); useEffect(() => { - if (isError) { + if (lineQuery.isError) { onLoadingError(); } - }, [ isError, onLoadingError ]); + }, [ lineQuery.isError, onLoadingError ]); return ( ); }; diff --git a/ui/stats/ChartsWidgetsList.tsx b/ui/stats/ChartsWidgetsList.tsx index 3d40f550ba..f1001774d1 100644 --- a/ui/stats/ChartsWidgetsList.tsx +++ b/ui/stats/ChartsWidgetsList.tsx @@ -95,6 +95,7 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in units={ chart.units || undefined } isPlaceholderData={ isPlaceholderData } onLoadingError={ handleChartLoadingError } + href={{ pathname: '/stats/[id]', query: { id: chart.id } }} /> )) } diff --git a/ui/stats/StatsDropdownMenu.tsx b/ui/stats/StatsDropdownMenu.tsx index 203e3c79f0..89c93c2ef2 100644 --- a/ui/stats/StatsDropdownMenu.tsx +++ b/ui/stats/StatsDropdownMenu.tsx @@ -5,7 +5,7 @@ import Menu from 'ui/shared/chakra/Menu'; import IconSvg from 'ui/shared/IconSvg'; type Props = { - items: Array<{id: T; title: string}>; + items: ReadonlyArray<{id: T; title: string}>; selectedId: T; onSelect: (id: T) => void; } @@ -23,7 +23,7 @@ export function StatsDropdownMenu({ items, selectedId, onSelec > ({ - id: id, - title: STATS_INTERVALS[id as StatsIntervalIds].title, -})) as Array; - type Props = { sections?: Array; currentSection: string; @@ -37,24 +32,25 @@ const StatsFilters = ({ }: Props) => { const sectionsList = [ { id: 'all', - title: 'All', + title: 'All stats', }, ... (sections || []) ]; return ( - { isLoading ? : ( + { isLoading ? : ( - { isLoading ? : ( - - ) } + diff --git a/ui/stats/constants/index.ts b/ui/stats/constants/index.ts index bf77117c4c..23d52006c6 100644 --- a/ui/stats/constants/index.ts +++ b/ui/stats/constants/index.ts @@ -1,23 +1,48 @@ +import { Resolution } from '@blockscout/stats-types'; import type { StatsIntervalIds } from 'types/client/stats'; -export const STATS_INTERVALS: { [key in StatsIntervalIds]: { title: string; start?: Date } } = { +export const STATS_RESOLUTIONS: Array<{id: Resolution; title: string }> = [ + { + id: Resolution.DAY, + title: 'Day', + }, + { + id: Resolution.WEEK, + title: 'Week', + }, + { + id: Resolution.MONTH, + title: 'Month', + }, + { + id: Resolution.YEAR, + title: 'Year', + }, +]; + +export const STATS_INTERVALS: { [key in StatsIntervalIds]: { title: string; shortTitle: string; start?: Date } } = { all: { title: 'All time', + shortTitle: 'All time', }, oneMonth: { title: '1 month', + shortTitle: '1M', start: getStartDateInPast(1), }, threeMonths: { title: '3 months', + shortTitle: '3M', start: getStartDateInPast(3), }, sixMonths: { title: '6 months', + shortTitle: '6M', start: getStartDateInPast(6), }, oneYear: { title: '1 year', + shortTitle: '1Y', start: getStartDateInPast(12), }, }; diff --git a/ui/txs/TxsStats.tsx b/ui/txs/TxsStats.tsx index 50327362ab..763d6eada7 100644 --- a/ui/txs/TxsStats.tsx +++ b/ui/txs/TxsStats.tsx @@ -46,7 +46,7 @@ const TxsStats = () => { value={ Number(txsStatsQuery.data?.transactions_count_24h).toLocaleString() } period="24h" isLoading={ txsStatsQuery.isPlaceholderData } - href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'newTxns' } } : undefined } + href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'newTxns' } } : undefined } /> { valuePostfix={ thinsp + config.chain.currency.symbol } period="24h" isLoading={ txsStatsQuery.isPlaceholderData } - href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'txnsFee' } } : undefined } + href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'txnsFee' } } : undefined } /> { valuePostfix={ txFeeAvg.usd ? undefined : thinsp + config.chain.currency.symbol } period="24h" isLoading={ txsStatsQuery.isPlaceholderData } - href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'averageTxnFee' } } : undefined } + href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'averageTxnFee' } } : undefined } /> ); diff --git a/ui/verifiedContracts/VerifiedContractsCounters.tsx b/ui/verifiedContracts/VerifiedContractsCounters.tsx index 708b29d823..ba0c5e8a26 100644 --- a/ui/verifiedContracts/VerifiedContractsCounters.tsx +++ b/ui/verifiedContracts/VerifiedContractsCounters.tsx @@ -25,7 +25,8 @@ const VerifiedContractsCounters = () => { diff={ countersQuery.data.new_smart_contracts_24h } diffFormatted={ Number(countersQuery.data.new_smart_contracts_24h).toLocaleString() } isLoading={ countersQuery.isPlaceholderData } - href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'contractsGrowth' } } : undefined } + // there is no stats for contracts growth for now + // href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'contractsGrowth' } } : undefined } /> { diff={ countersQuery.data.new_verified_smart_contracts_24h } diffFormatted={ Number(countersQuery.data.new_verified_smart_contracts_24h).toLocaleString() } isLoading={ countersQuery.isPlaceholderData } - href={ config.features.stats.isEnabled ? { pathname: '/stats', query: { chartId: 'verifiedContractsGrowth' } } : undefined } + href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'verifiedContractsGrowth' } } : undefined } /> ); diff --git a/yarn.lock b/yarn.lock index bf481be684..4b2621d991 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1327,10 +1327,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@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-1.6.0.tgz#cdb27ab3d3cb1eef7b8b069c39d4e09afda1aec9" - integrity sha512-MzItYOsLa3zgoFzRgFAgg7gynSXG0w/GqHzg5BGHcBPbPSp/g7A6mMtyIchI6TnZxxnCwziHHvzmJFXz11emUg== +"@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/visualizer-types@0.2.0": version "0.2.0"