Commit bfb08554 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Block Countdown page (#2091)

* page placeholder

* block info snippet

* add actions for calendar buttons

* add timer

* add redirect when timer runs out

* add redirect from not found block page

* block countdown index page

* link to countdown index page

* add link to search suggest

* add URL to an event

* add link to block countdown on mobile

* tests

* add countdown text to search result page

* update margins

* show block countdown when search results are not empty
parent 83cffb94
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm2 .5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm2 .5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3ZM15 3a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-4Zm4 2.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3ZM13 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4Zm2 .5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3Z" clip-rule="evenodd"/>
</svg>
<svg viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 4.167c0-.92.746-1.667 1.667-1.667H8c.92 0 1.667.746 1.667 1.667V7.5c0 .92-.747 1.667-1.667 1.667H4.667C3.747 9.167 3 8.42 3 7.5V4.167Zm1.667.5a.5.5 0 0 1 .5-.5H7.5a.5.5 0 0 1 .5.5V7a.5.5 0 0 1-.5.5H5.167a.5.5 0 0 1-.5-.5V4.667ZM3 12.5c0-.92.746-1.667 1.667-1.667H8c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.747 1.667-1.667 1.667H4.667C3.747 17.5 3 16.754 3 15.833V12.5Zm1.667.5a.5.5 0 0 1 .5-.5H7.5a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H5.167a.5.5 0 0 1-.5-.5V13ZM13 2.5c-.92 0-1.667.746-1.667 1.667V7.5c0 .92.746 1.667 1.667 1.667h3.333C17.253 9.167 18 8.42 18 7.5V4.167c0-.92-.746-1.667-1.667-1.667H13Zm3.333 2.167a.5.5 0 0 0-.5-.5H13.5a.5.5 0 0 0-.5.5V7a.5.5 0 0 0 .5.5h2.333a.5.5 0 0 0 .5-.5V4.667Zm-5 7.833c0-.92.746-1.667 1.667-1.667h3.333c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.746 1.667-1.667 1.667H13c-.92 0-1.667-.746-1.667-1.667V12.5ZM13 13a.5.5 0 0 1 .5-.5h2.333a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H13.5a.5.5 0 0 1-.5-.5V13Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 241 185" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5 57.705 120.306 22.5l62.743 35.205v72.397l-48.007 26.842v-11.695l37.055-21.373-8.814-5.11.794-1.37 8.681 5.034V70.1l-23.049 13.044c.109-.729.164-1.464.166-2.2v-9.556l17.678-10.174-46.559-26.74v10.17h-1.583V34.605L74.17 61.214l16.33 9.625V81.064c.002.588.037 1.175.107 1.76L68.09 70.1v52.366l8.708-4.994.787 1.373-8.808 5.052 37.262 21.819.234 11.697L58.5 129.576V57.705Zm32.219 51.843c-.11.642-.179 1.29-.206 1.939l-5.774 3.289-.784-1.375 6.764-3.853Zm24.672 28.095h10.139v22.05l-5.147 2.807-4.992-2.806v-22.051Zm10.139-10h-10.139v-4.78h10.139v4.78Zm0-29.66-1.706-1.412 14.301-11.83a4.797 4.797 0 0 0 1.285-1.688 5.169 5.169 0 0 0 .465-2.137v-3.772l-4.375 2.518v1.254l-1.586 1.315h-2.878l-10.73 6.175-10.478-6.175h-3.504l-1.449-1.195V79.31l-4.375-2.578v4.303c.002.741.161 1.472.465 2.136.245.535.578 1.014.982 1.414.097.096.198.187.303.274l4.949 4.063 9.352 7.648-1.16.95v5.968l2.609-2.137v7.17c0 .634.23 1.242.641 1.69.41.448.966.7 1.547.7.58 0 1.136-.252 1.546-.7a2.503 2.503 0 0 0 .641-1.69v-7.141l3.155 2.607v-6.006Zm-4.536-27.703h-1.583v-.032h1.583v.032Zm28.844 40.893 6.353 3.66.79-1.372-7.414-4.27c.134.655.224 1.317.271 1.982ZM120.994 55.5h-1.583v-3.763h1.583V55.5Z" fill="currentColor"/>
<path d="M138.125 84.74a4.728 4.728 0 0 0 1.285-1.688 5.169 5.169 0 0 0 .465-2.136V70.28c0-1.268-.461-2.483-1.281-3.38-.821-.896-1.934-1.4-3.094-1.4h-30.625c-1.16 0-2.273.504-3.094 1.4-.82.897-1.281 2.112-1.281 3.38v10.756c.002.741.161 1.472.465 2.136a4.729 4.729 0 0 0 1.285 1.688l4.949 4.063 9.352 7.648-14.301 11.712a4.725 4.725 0 0 0-1.285 1.688 5.166 5.166 0 0 0-.465 2.136v10.756c0 1.267.461 2.483 1.281 3.38.821.896 1.934 1.4 3.094 1.4H135.5c1.16 0 2.273-.504 3.094-1.4.82-.897 1.281-2.113 1.281-3.38v-10.636a5.174 5.174 0 0 0-.465-2.137 4.733 4.733 0 0 0-1.285-1.688l-14.301-11.83 6.207-5.14 8.094-6.692Zm-33.25-14.46H135.5v10.636l-1.586 1.315h-27.59l-1.449-1.195V70.28Zm30.625 41.947v10.636h-30.625v-10.756L118 101.352v7.17c0 .634.23 1.242.641 1.69.41.448.966.7 1.547.7.58 0 1.136-.252 1.546-.7a2.503 2.503 0 0 0 .641-1.69v-7.141l13.125 10.846Z" fill="currentColor" stroke="currentColor" stroke-width="4"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.125 6.906a1.257 1.257 0 0 0 .5-1V3.125a1.25 1.25 0 0 0-1.25-1.25h-8.75a1.25 1.25 0 0 0-1.25 1.25v2.813a1.258 1.258 0 0 0 .5 1L6.289 8l2.672 2-4.086 3.063a1.258 1.258 0 0 0-.5 1v2.812a1.25 1.25 0 0 0 1.25 1.25h8.75a1.25 1.25 0 0 0 1.25-1.25v-2.781a1.257 1.257 0 0 0-.5-1L11.039 10l1.774-1.344 2.312-1.75Zm-9.5-3.781h8.75v2.781l-.453.344H6.039l-.414-.313V3.126Zm8.75 10.969v2.781h-8.75v-2.813l3.75-2.812v1.875a.625.625 0 1 0 1.25 0v-1.867l3.75 2.836Z" fill="currentColor"/>
</svg>
...@@ -43,7 +43,7 @@ import type { ...@@ -43,7 +43,7 @@ import type {
ArbitrumL2BatchBlocks, ArbitrumL2BatchBlocks,
} from 'types/api/arbitrumL2'; } from 'types/api/arbitrumL2';
import type { TxBlobs, Blob } from 'types/api/blobs'; import type { TxBlobs, Blob } from 'types/api/blobs';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block'; import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse, BlockCountdownResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts'; import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig } from 'types/api/configs'; import type { BackendVersionConfig } from 'types/api/configs';
import type { import type {
...@@ -852,6 +852,9 @@ export const RESOURCES = { ...@@ -852,6 +852,9 @@ export const RESOURCES = {
graphql: { graphql: {
path: '/api/v1/graphql', path: '/api/v1/graphql',
}, },
block_countdown: {
path: '/api',
},
}; };
export type ResourceName = keyof typeof RESOURCES; export type ResourceName = keyof typeof RESOURCES;
...@@ -937,6 +940,7 @@ Q extends 'stats_lines' ? stats.LineCharts : ...@@ -937,6 +940,7 @@ Q extends 'stats_lines' ? stats.LineCharts :
Q extends 'stats_line' ? stats.LineChart : Q extends 'stats_line' ? stats.LineChart :
Q extends 'blocks' ? BlocksResponse : Q extends 'blocks' ? BlocksResponse :
Q extends 'block' ? Block : Q extends 'block' ? Block :
Q extends 'block_countdown' ? BlockCountdownResponse :
Q extends 'block_txs' ? BlockTransactionsResponse : Q extends 'block_txs' ? BlockTransactionsResponse :
Q extends 'block_withdrawals' ? BlockWithdrawalsResponse : Q extends 'block_withdrawals' ? BlockWithdrawalsResponse :
Q extends 'txs_stats' ? TransactionsStats : Q extends 'txs_stats' ? TransactionsStats :
......
...@@ -9,6 +9,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -9,6 +9,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/tx/[hash]': 'Regular page', '/tx/[hash]': 'Regular page',
'/blocks': 'Root page', '/blocks': 'Root page',
'/block/[height_or_hash]': 'Regular page', '/block/[height_or_hash]': 'Regular page',
'/block/countdown': 'Regular page',
'/block/countdown/[height]': 'Regular page',
'/accounts': 'Root page', '/accounts': 'Root page',
'/address/[hash]': 'Regular page', '/address/[hash]': 'Regular page',
'/verified-contracts': 'Root page', '/verified-contracts': 'Root page',
......
...@@ -13,6 +13,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -13,6 +13,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/tx/[hash]': 'View transaction %hash% on %network_title%', '/tx/[hash]': 'View transaction %hash% on %network_title%',
'/blocks': DEFAULT_TEMPLATE, '/blocks': DEFAULT_TEMPLATE,
'/block/[height_or_hash]': 'View the transactions, token transfers, and uncles for block %height_or_hash%', '/block/[height_or_hash]': 'View the transactions, token transfers, and uncles for block %height_or_hash%',
'/block/countdown': DEFAULT_TEMPLATE,
'/block/countdown/[height]': DEFAULT_TEMPLATE,
'/accounts': DEFAULT_TEMPLATE, '/accounts': DEFAULT_TEMPLATE,
'/address/[hash]': 'View the account balance, transactions, and other data for %hash% on the %network_title%', '/address/[hash]': 'View the account balance, transactions, and other data for %hash% on the %network_title%',
'/verified-contracts': DEFAULT_TEMPLATE, '/verified-contracts': DEFAULT_TEMPLATE,
......
...@@ -9,6 +9,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -9,6 +9,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/tx/[hash]': '%network_name% transaction %hash%', '/tx/[hash]': '%network_name% transaction %hash%',
'/blocks': '%network_name% blocks', '/blocks': '%network_name% blocks',
'/block/[height_or_hash]': '%network_name% block %height_or_hash%', '/block/[height_or_hash]': '%network_name% block %height_or_hash%',
'/block/countdown': '%network_name% block countdown index',
'/block/countdown/[height]': '%network_name% block %height% countdown',
'/accounts': '%network_name% top accounts', '/accounts': '%network_name% top accounts',
'/address/[hash]': '%network_name% address details for %hash%', '/address/[hash]': '%network_name% address details for %hash%',
'/verified-contracts': 'Verified %network_name% contracts lookup - %network_name% explorer', '/verified-contracts': 'Verified %network_name% contracts lookup - %network_name% explorer',
......
...@@ -7,6 +7,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -7,6 +7,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/tx/[hash]': 'Transaction details', '/tx/[hash]': 'Transaction details',
'/blocks': 'Blocks', '/blocks': 'Blocks',
'/block/[height_or_hash]': 'Block details', '/block/[height_or_hash]': 'Block details',
'/block/countdown': 'Block countdown search',
'/block/countdown/[height]': 'Block countdown',
'/accounts': 'Top accounts', '/accounts': 'Top accounts',
'/address/[hash]': 'Address details', '/address/[hash]': 'Address details',
'/verified-contracts': 'Verified contracts', '/verified-contracts': 'Verified contracts',
......
...@@ -5,3 +5,5 @@ export const IPFS_PREFIX = /^ipfs:\/\//i; ...@@ -5,3 +5,5 @@ export const IPFS_PREFIX = /^ipfs:\/\//i;
export const HEX_REGEXP = /^(?:0x)?[\da-fA-F]+$/; export const HEX_REGEXP = /^(?:0x)?[\da-fA-F]+$/;
export const FILE_EXTENSION = /\.([\da-z]+)$/i; export const FILE_EXTENSION = /\.([\da-z]+)$/i;
export const BLOCK_HEIGHT = /^\d+$/;
...@@ -33,6 +33,8 @@ declare module "nextjs-routes" { ...@@ -33,6 +33,8 @@ declare module "nextjs-routes" {
| StaticRoute<"/batches"> | StaticRoute<"/batches">
| DynamicRoute<"/blobs/[hash]", { "hash": string }> | DynamicRoute<"/blobs/[hash]", { "hash": string }>
| DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }> | DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }>
| DynamicRoute<"/block/countdown/[height]", { "height": string }>
| StaticRoute<"/block/countdown">
| StaticRoute<"/blocks"> | StaticRoute<"/blocks">
| StaticRoute<"/contract-verification"> | StaticRoute<"/contract-verification">
| StaticRoute<"/csv-export"> | StaticRoute<"/csv-export">
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
const BlockCountdown = dynamic(() => import('ui/pages/BlockCountdown'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/block/countdown/[height]" query={ props.query }>
<BlockCountdown/>
</PageNextJs>
);
};
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
const BlockCountdownIndex = dynamic(() => import('ui/pages/BlockCountdownIndex'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/block/countdown" query={ props.query }>
<BlockCountdownIndex/>
</PageNextJs>
);
};
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
| "ABI" | "ABI"
| "API" | "API"
| "apps_list" | "apps_list"
| "apps_xs" | "apps_slim"
| "apps" | "apps"
| "arrows/down-right" | "arrows/down-right"
| "arrows/east-mini" | "arrows/east-mini"
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
| "blobs/image" | "blobs/image"
| "blobs/raw" | "blobs/raw"
| "blobs/text" | "blobs/text"
| "block_countdown"
| "block_slim" | "block_slim"
| "block" | "block"
| "brands/blockscout" | "brands/blockscout"
...@@ -70,6 +71,7 @@ ...@@ -70,6 +71,7 @@
| "globe-b" | "globe-b"
| "globe" | "globe"
| "graphQL" | "graphQL"
| "hourglass"
| "info" | "info"
| "integration/full" | "integration/full"
| "integration/partial" | "integration/partial"
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 21">
<g clip-path="url(#a)">
<path fill="#fff" d="m20.226 7.09-.001-.717a26.42 26.42 0 0 0-.01-.604 8.818 8.818 0 0 0-.115-1.313 4.42 4.42 0 0 0-.412-1.25 4.206 4.206 0 0 0-1.836-1.836 4.403 4.403 0 0 0-1.249-.41A8.815 8.815 0 0 0 15.29.843c-.2-.006-.402-.008-.603-.01H5.765c-.2.001-.402.004-.603.01a8.92 8.92 0 0 0-1.314.115c-.44.079-.85.209-1.248.411A4.207 4.207 0 0 0 .764 3.207c-.204.4-.333.809-.412 1.249a8.69 8.69 0 0 0-.115 1.313c-.006.201-.009.403-.01.604l-.001.717v7.487l.001.717c.001.2.004.402.01.603.012.438.038.88.115 1.314.08.44.209.85.412 1.25a4.202 4.202 0 0 0 1.837 1.835c.399.204.808.333 1.248.411.433.078.876.104 1.314.116.2.006.402.008.603.01h8.921c.2-.002.402-.004.603-.01a8.776 8.776 0 0 0 1.314-.116 4.43 4.43 0 0 0 1.248-.41 4.204 4.204 0 0 0 1.837-1.837c.203-.4.332-.809.412-1.249.077-.433.103-.875.115-1.314.006-.2.008-.402.01-.603V7.09Z"/>
<path fill="#000" d="M7.69 17.195V7.891H7.3L4.93 9.46v.396l2.343-1.534H7.3v8.873h.39Zm2.316-9.305v.357h5.111v.028l-4.105 8.921h.44l4.07-8.928V7.89h-5.516Z"/>
<path fill="red" d="M3.86 6.066V4.054h.007l.795 1.806h.264l.793-1.806h.01v2.012h.263V3.4h-.28L4.8 5.536h-.007l-.916-2.134h-.28v2.664h.264Zm3.566-2.079c-.556 0-.865.405-.865.953v.2c0 .552.306.958.865.958.559 0 .863-.406.863-.957V4.94c.001-.548-.308-.953-.863-.953Zm0 .252c.363 0 .576.271.576.715v.172c0 .445-.213.717-.576.717-.365 0-.577-.274-.577-.717v-.172c0-.443.212-.715.577-.715Zm1.402 1.827h.287V4.813c0-.293.16-.563.535-.563.32 0 .526.194.526.543v1.27h.287V4.75c0-.496-.315-.764-.736-.764-.336 0-.528.176-.604.313h-.008v-.28h-.287v2.048Zm2.956-2.081c-.509 0-.819.397-.819.951v.21c0 .564.287.95.819.95a.67.67 0 0 0 .613-.35h.007v.319h.271V3.258h-.287v1.055h-.007a.672.672 0 0 0-.597-.328Zm.026.257c.363 0 .586.285.586.707v.19c0 .44-.217.703-.583.703-.324 0-.56-.236-.56-.705v-.185c0-.478.24-.71.557-.71ZM14.453 5.8h.007v.266h.28V4.644c0-.42-.309-.657-.735-.657-.474 0-.718.248-.742.604h.271c.023-.217.178-.356.46-.356.29 0 .459.155.459.442v.22h-.557c-.467.002-.713.229-.713.583 0 .375.271.62.669.62.312-.002.502-.136.6-.3Zm-.534.051c-.23 0-.44-.123-.44-.377 0-.199.127-.342.426-.342h.548v.252c0 .275-.23.467-.534.467Zm2.935-1.831h-.306l-.584 1.712h-.01l-.585-1.712h-.32l.765 2.078-.04.125c-.06.211-.163.334-.404.334a.96.96 0 0 1-.15-.012v.245c.051.007.127.016.195.016.399 0 .536-.274.643-.569l.058-.153.738-2.064Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M.226.833h20v20h-20z"/>
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 21">
<g clip-path="url(#a)">
<path fill="#fff" d="m15.489 5.57-4.737-.526-5.79.526-.526 5.263.526 5.263 5.264.658 5.263-.658.526-5.394-.526-5.132Z"/>
<path fill="#1A73E8" d="M7.122 13.736c-.394-.266-.666-.654-.815-1.167l.913-.376c.083.315.228.56.435.734.205.173.455.259.747.259.299 0 .555-.09.77-.272a.87.87 0 0 0 .322-.694.86.86 0 0 0-.34-.703c-.226-.18-.51-.272-.85-.272h-.527v-.904h.474c.292 0 .538-.079.738-.237a.78.78 0 0 0 .3-.648c0-.245-.09-.44-.269-.586-.179-.146-.405-.22-.68-.22-.268 0-.482.072-.64.215a1.26 1.26 0 0 0-.344.528l-.904-.377c.12-.34.34-.64.662-.898.322-.26.734-.39 1.234-.39.37 0 .703.071.997.215.295.143.527.342.694.594.167.254.25.539.25.854 0 .323-.078.595-.233.819a1.606 1.606 0 0 1-.573.514v.054c.3.125.542.316.735.572a1.5 1.5 0 0 1 .286.922c0 .357-.09.677-.272.957a1.89 1.89 0 0 1-.751.662 2.37 2.37 0 0 1-1.078.242 2.23 2.23 0 0 1-1.281-.397Zm5.604-4.532-.998.725-.5-.76 1.798-1.297h.69v6.12h-.99V9.203Z"/>
<path fill="#EA4335" d="m15.489 20.833 4.736-4.736-2.368-1.053-2.368 1.052-1.053 2.369 1.053 2.368Z"/>
<path fill="#34A853" d="m3.91 18.465 1.052 2.368H15.49v-4.736H4.962L3.91 18.465Z"/>
<path fill="#4285F4" d="M1.804.833C.932.833.226 1.54.226 2.413v13.683l2.368 1.053 2.368-1.053V5.57H15.49l1.052-2.368L15.49.833H1.804Z"/>
<path fill="#188038" d="M.226 16.097v3.157c0 .873.706 1.58 1.578 1.58h3.158v-4.737H.226Z"/>
<path fill="#FBBC04" d="M15.489 5.57v10.526h4.737V5.57l-2.369-1.052L15.49 5.57Z"/>
<path fill="#1967D2" d="M20.226 5.57V2.412a1.58 1.58 0 0 0-1.58-1.579H15.49V5.57h4.737Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M.226.833h20v20h-20z"/>
</clipPath>
</defs>
</svg>
...@@ -103,3 +103,12 @@ export type BlockWithdrawalsItem = { ...@@ -103,3 +103,12 @@ export type BlockWithdrawalsItem = {
receiver: AddressParam; receiver: AddressParam;
validator_index: number; validator_index: number;
} }
export interface BlockCountdownResponse {
result: {
CountdownBlock: string;
CurrentBlock: string;
EstimateTimeInSec: string;
RemainingBlock: string;
} | null;
}
import type * as api from 'types/api/search';
export interface SearchResultFutureBlock {
type: 'block';
block_type: 'block';
block_number: number | string;
block_hash: string;
timestamp: undefined;
url?: string; // not used by the frontend, we build the url ourselves
}
export type SearchResultBlock = api.SearchResultBlock | SearchResultFutureBlock;
export type SearchResultItem = api.SearchResultItem | SearchResultBlock;
import { HStack, StackDivider, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { SECOND } from 'lib/consts';
import BlockCountdownTimerItem from './BlockCountdownTimerItem';
import splitSecondsInPeriods from './splitSecondsInPeriods';
interface Props {
value: number;
onFinish: () => void;
}
const BlockCountdownTimer = ({ value: initialValue, onFinish }: Props) => {
const [ value, setValue ] = React.useState(initialValue);
const bgColor = useColorModeValue('gray.50', 'whiteAlpha.100');
React.useEffect(() => {
const intervalId = window.setInterval(() => {
setValue((prev) => {
if (prev > 1) {
return prev - 1;
}
onFinish();
return 0;
});
}, SECOND);
return () => {
window.clearInterval(intervalId);
};
}, [ initialValue, onFinish ]);
const periods = splitSecondsInPeriods(value);
return (
<HStack
bgColor={ bgColor }
mt={{ base: 6, lg: 8 }}
p={{ base: 3, lg: 4 }}
borderRadius="base"
divider={ <StackDivider borderColor="divider"/> }
>
<BlockCountdownTimerItem label="Days" value={ periods.days }/>
<BlockCountdownTimerItem label="Hours" value={ periods.hours }/>
<BlockCountdownTimerItem label="Minutes" value={ periods.minutes }/>
<BlockCountdownTimerItem label="Seconds" value={ periods.seconds }/>
</HStack>
);
};
export default React.memo(BlockCountdownTimer);
import { Box } from '@chakra-ui/react';
import React from 'react';
import TruncatedValue from 'ui/shared/TruncatedValue';
interface Props {
label: string;
value: string;
}
const BlockCountdownTimerItem = ({ label, value }: Props) => {
return (
<Box
minW={{ base: '70px', lg: '100px' }}
textAlign="center"
overflow="hidden"
flex="1 1 auto"
>
<TruncatedValue
value={ value }
fontFamily="heading"
fontSize={{ base: '40px', lg: '48px' }}
lineHeight="48px"
fontWeight={ 600 }
w="100%"
/>
<Box fontSize="sm" lineHeight="20px" mt={ 1 } color="text_secondary">{ label }</Box>
</Box>
);
};
export default React.memo(BlockCountdownTimerItem);
import { route } from 'nextjs-routes';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
interface Params {
timeFromNow: number;
blockHeight: string;
}
const DATE_FORMAT = 'YYYYMMDDTHHmm';
export default function createGoogleCalendarLink({ timeFromNow, blockHeight }: Params): string {
const date = dayjs().add(timeFromNow, 's');
const name = `Block #${ blockHeight } reminder | ${ config.chain.name }`;
const description = `#${ blockHeight } block creation time on ${ config.chain.name } blockchain.`;
const startTime = date.format(DATE_FORMAT);
const endTime = date.add(15, 'minutes').format(DATE_FORMAT);
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const blockUrl = config.app.baseUrl + route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: blockHeight } });
const url = new URL('calendar/render', 'https://www.google.com/');
url.searchParams.append('action', 'TEMPLATE');
url.searchParams.append('text', name);
url.searchParams.append('details', description + '\n' + blockUrl);
url.searchParams.append('dates', `${ startTime }/${ endTime }`);
url.searchParams.append('ctz', timeZone);
return url.toString();
}
import { route } from 'nextjs-routes';
import config from 'configs/app';
import type dayjs from 'lib/date/dayjs';
interface Params {
date: dayjs.Dayjs;
blockHeight: string;
}
const DATE_FORMAT = 'YYYYMMDDTHHmmss';
export default function createIcsFileBlob({ date, blockHeight }: Params): Blob {
const name = `Block #${ blockHeight } reminder | ${ config.chain.name }`;
const description = `#${ blockHeight } block creation time on ${ config.chain.name } blockchain.`;
const startTime = date.format(DATE_FORMAT);
const endTime = date.add(15, 'minutes').format(DATE_FORMAT);
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const blockUrl = config.app.baseUrl + route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: blockHeight } });
const icsContent = `BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:${ name }
DESCRIPTION:${ description }
DTSTART;TZID=${ timeZone }:${ startTime }
DTEND;TZID=${ timeZone }:${ endTime }
URL:${ blockUrl }
END:VEVENT
END:VCALENDAR`;
const blob = new Blob([ icsContent ], { type: 'text/calendar' });
return blob;
}
import _padStart from 'lodash/padStart';
export default function splitSecondsInPeriods(value: number) {
const seconds = value % 60;
const minutes = (value - seconds) / 60 % 60;
const hours = (value - seconds - minutes * 60) / (60 * 60) % 24;
const days = (value - seconds - minutes * 60 - hours * 60 * 60) / (60 * 60 * 24);
return {
seconds: _padStart(String(seconds), 2, '0'),
minutes: _padStart(String(minutes), 2, '0'),
hours: _padStart(String(hours), 2, '0'),
days: _padStart(String(days), 2, '0'),
};
}
...@@ -5,6 +5,8 @@ import React from 'react'; ...@@ -5,6 +5,8 @@ import React from 'react';
import type { SocketMessage } from 'lib/socket/types'; import type { SocketMessage } from 'lib/socket/types';
import type { BlockType, BlocksResponse } from 'types/api/block'; import type { BlockType, BlocksResponse } from 'types/api/block';
import { route } from 'nextjs-routes';
import { getResourceKey } from 'lib/api/useApiQuery'; import { getResourceKey } from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
...@@ -13,6 +15,8 @@ import BlocksList from 'ui/blocks/BlocksList'; ...@@ -13,6 +15,8 @@ import BlocksList from 'ui/blocks/BlocksList';
import BlocksTable from 'ui/blocks/BlocksTable'; import BlocksTable from 'ui/blocks/BlocksTable';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice'; import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
...@@ -109,8 +113,12 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => { ...@@ -109,8 +113,12 @@ const BlocksContent = ({ type, query, enableSocket = true, top }: Props) => {
</> </>
) : null; ) : null;
const actionBar = isMobile && query.pagination.isVisible ? ( const actionBar = isMobile ? (
<ActionBar mt={ -6 }> <ActionBar mt={ -6 }>
<LinkInternal display="inline-flex" alignItems="center" href={ route({ pathname: '/block/countdown' }) }>
<IconSvg name="hourglass" boxSize={ 5 } mr={ 2 }/>
<span>Block countdown</span>
</LinkInternal>
<Pagination ml="auto" { ...query.pagination }/> <Pagination ml="auto" { ...query.pagination }/>
</ActionBar> </ActionBar>
) : null; ) : null;
......
...@@ -3,9 +3,13 @@ import React from 'react'; ...@@ -3,9 +3,13 @@ import React from 'react';
import type { PaginationParams } from 'ui/shared/pagination/types'; import type { PaginationParams } from 'ui/shared/pagination/types';
import { route } from 'nextjs-routes';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { nbsp } from 'lib/html-entities'; import { nbsp } from 'lib/html-entities';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS } from 'stubs/stats';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
interface Props { interface Props {
...@@ -31,6 +35,10 @@ const BlocksTabSlot = ({ pagination }: Props) => { ...@@ -31,6 +35,10 @@ const BlocksTabSlot = ({ pagination }: Props) => {
</Skeleton> </Skeleton>
</Box> </Box>
) } ) }
<LinkInternal display="inline-flex" alignItems="center" href={ route({ pathname: '/block/countdown' }) }>
<IconSvg name="hourglass" boxSize={ 5 } mr={ 2 }/>
<span>Block countdown</span>
</LinkInternal>
<Pagination my={ 1 } { ...pagination }/> <Pagination my={ 1 } { ...pagination }/>
</Flex> </Flex>
); );
......
...@@ -115,7 +115,15 @@ const BlockPageContent = () => { ...@@ -115,7 +115,15 @@ const BlockPageContent = () => {
}, [ appProps.referrer ]); }, [ appProps.referrer ]);
throwOnAbsentParamError(heightOrHash); throwOnAbsentParamError(heightOrHash);
throwOnResourceLoadError(blockQuery);
if (blockQuery.isError) {
if (!blockQuery.isDegradedData && blockQuery.error.status === 404 && !heightOrHash.startsWith('0x')) {
router.push({ pathname: '/block/countdown/[height]', query: { height: heightOrHash } });
return null;
} else {
throwOnResourceLoadError(blockQuery);
}
}
const title = (() => { const title = (() => {
switch (blockQuery.data?.type) { switch (blockQuery.data?.type) {
......
import React from 'react';
import { test, expect } from 'playwright/lib';
import BlockCountdown from './BlockCountdown';
test('short period until the block +@mobile', async({ render, mockApiResponse }) => {
const height = '1234567890';
const hooksConfig = {
router: {
query: { height: height },
},
};
await mockApiResponse('block_countdown', {
result: {
CountdownBlock: height,
CurrentBlock: '1234567700',
RemainingBlock: '190',
EstimateTimeInSec: String(24 * 60 * 60 + 3 * 60 * 60 + 42 * 60 + 11),
},
}, {
queryParams: {
module: 'block',
action: 'getblockcountdown',
blockno: height,
},
});
const component = await render(<BlockCountdown/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
test('long period until the block +@mobile', async({ render, mockApiResponse }) => {
const height = '123456789012345678901234567890';
const hooksConfig = {
router: {
query: { height: height },
},
};
await mockApiResponse('block_countdown', {
result: {
CountdownBlock: height,
CurrentBlock: '1234567700',
RemainingBlock: '123456789012345678900000000190',
EstimateTimeInSec: String(1234567890 * 24 * 60 * 60 + 3 * 60 * 60 + 42 * 60 + 11),
},
}, {
queryParams: {
module: 'block',
action: 'getblockcountdown',
blockno: height,
},
});
const component = await render(<BlockCountdown/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
import { Box, Center, Flex, Heading, Image, useColorModeValue, Grid, Button } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { route } from 'nextjs-routes';
import useApiQuery from 'lib/api/useApiQuery';
import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import BlockCountdownTimer from 'ui/blockCountdown/BlockCountdownTimer';
import createGoogleCalendarLink from 'ui/blockCountdown/createGoogleCalendarLink';
import createIcsFileBlob from 'ui/blockCountdown/createIcsFileBlob';
import ContentLoader from 'ui/shared/ContentLoader';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal';
import StatsWidget from 'ui/shared/stats/StatsWidget';
import TruncatedValue from 'ui/shared/TruncatedValue';
const BlockCountdown = () => {
const router = useRouter();
const height = getQueryParamString(router.query.height);
const iconColor = useColorModeValue('gray.300', 'gray.600');
const buttonBgColor = useColorModeValue('gray.100', 'gray.700');
const { data, isPending, isError, error } = useApiQuery('block_countdown', {
queryParams: {
module: 'block',
action: 'getblockcountdown',
blockno: height,
},
});
const handleAddToAppleCalClick = React.useCallback(() => {
if (!data?.result?.EstimateTimeInSec) {
return;
}
const fileBlob = createIcsFileBlob({ blockHeight: height, date: dayjs().add(Number(data.result.EstimateTimeInSec), 's') });
downloadBlob(fileBlob, `Block #${ height } creation event.ics`);
}, [ data?.result?.EstimateTimeInSec, height ]);
const handleTimerFinish = React.useCallback(() => {
window.location.assign(route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: height } }));
}, [ height ]);
React.useEffect(() => {
if (!isError && !isPending && !data.result) {
handleTimerFinish();
}
}, [ data?.result, handleTimerFinish, isError, isPending ]);
if (isError) {
throwOnResourceLoadError({ isError, error, resource: 'block_countdown' });
}
if (isPending || !data?.result) {
return <Center h="100%"><ContentLoader/></Center>;
}
return (
<Center h="100%" alignItems={{ base: 'flex-start', lg: 'center' }}>
<Flex flexDir="column" w="fit-content" maxW={{ base: '100%', lg: '700px', xl: '1000px' }}>
<Flex columnGap={ 8 } alignItems="flex-start" justifyContent={{ base: 'space-between', lg: undefined }} w="100%">
<Box maxW={{ base: 'calc(100% - 65px - 32px)', lg: 'calc(100% - 125px - 32px)' }}>
<Heading
fontSize={{ base: '18px', lg: '32px' }}
lineHeight={{ base: '24px', lg: '40px' }}
h={{ base: '24px', lg: '40px' }}
>
<TruncatedValue value={ `Block #${ height }` } w="100%"/>
</Heading>
<Box mt={ 2 } color="text_secondary">
<Box fontWeight={ 600 }>Estimated target date</Box>
<Box>{ dayjs().add(Number(data.result.EstimateTimeInSec), 's').format('llll') }</Box>
</Box>
<Flex columnGap={ 2 } mt={ 3 }>
<LinkExternal
variant="subtle"
fontSize="sm"
lineHeight="20px"
px={ 2 }
display="inline-flex"
href={ createGoogleCalendarLink({ blockHeight: height, timeFromNow: Number(data.result.EstimateTimeInSec) }) }
>
<Image src="/static/google_calendar.svg" alt="Google calendar logo" boxSize={ 5 } mr={ 2 }/>
<span>Google</span>
</LinkExternal>
<Button
variant="subtle"
fontWeight={ 400 }
px={ 2 }
size="sm"
bgColor={ buttonBgColor }
display="inline-flex"
onClick={ handleAddToAppleCalClick }
>
<Image src="/static/apple_calendar.svg" alt="Apple calendar logo" boxSize={ 5 } mr={ 2 }/>
<span>Apple</span>
</Button>
</Flex>
</Box>
<IconSvg name="block_slim" w={{ base: '65px', lg: '125px' }} h={{ base: '75px', lg: '140px' }} color={ iconColor } flexShrink={ 0 }/>
</Flex>
{ data.result.EstimateTimeInSec && (
<BlockCountdownTimer
value={ Math.ceil(Number(data.result.EstimateTimeInSec)) }
onFinish={ handleTimerFinish }
/>
) }
<Grid gridTemplateColumns="repeat(2, calc(50% - 4px))" columnGap={ 2 } mt={ 2 }>
<StatsWidget label="Remaining blocks" value={ data.result.RemainingBlock } icon="apps_slim"/>
<StatsWidget label="Current block" value={ data.result.CurrentBlock } icon="block_slim"/>
</Grid>
</Flex>
</Center>
);
};
export default React.memo(BlockCountdown);
import React from 'react';
import { test, expect } from 'playwright/lib';
import BlockCountdownIndex from './BlockCountdownIndex';
test('base view +@mobile', async({ render }) => {
const component = await render(<BlockCountdownIndex/>);
await expect(component).toHaveScreenshot();
});
import { chakra, Box, Center, Heading, useColorModeValue } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import FilterInput from 'ui/shared/filters/FilterInput';
import IconSvg from 'ui/shared/IconSvg';
const BlockCountdownIndex = () => {
const router = useRouter();
const iconColor = useColorModeValue('gray.300', 'gray.600');
const handleFormSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
const searchTerm = formData.get('search_term');
if (typeof searchTerm === 'string' && searchTerm) {
router.push({ pathname: '/block/countdown/[height]', query: { height: searchTerm } }, undefined, { shallow: true });
}
}, [ router ]);
return (
<Center h="100%" justifyContent={{ base: 'flex-start', lg: 'center' }} flexDir="column" textAlign="center" pt={{ base: 8, lg: 0 }}>
<IconSvg name="block_countdown" color={ iconColor } w={{ base: '160px', lg: '240px' }} h={{ base: '123px', lg: '184px' }}/>
<Heading
fontSize={{ base: '18px', lg: '32px' }}
lineHeight={{ base: '24px', lg: '40px' }}
h={{ base: '24px', lg: '40px' }}
mt={{ base: 3, lg: 6 }}
>
Block countdown
</Heading>
<Box mt={ 2 }>
The estimated time for a block to be created and added to the blockchain.
</Box>
<chakra.form
noValidate
onSubmit={ handleFormSubmit }
w={{ base: '100%', lg: '360px' }}
mt={{ base: 3, lg: 6 }}
>
<FilterInput
placeholder="Search by block number"
size="xs"
type="number"
name="search_term"
/>
</chakra.form>
</Center>
);
};
export default React.memo(BlockCountdownIndex);
...@@ -185,3 +185,30 @@ test.describe('with apps', () => { ...@@ -185,3 +185,30 @@ test.describe('with apps', () => {
await expect(component.locator('main')).toHaveScreenshot(); await expect(component.locator('main')).toHaveScreenshot();
}); });
}); });
test.describe('block countdown', () => {
const blockHeight = '1234567890';
const hooksConfig = {
router: {
query: { q: blockHeight },
},
};
test('no results', async({ render, mockApiResponse }) => {
await mockApiResponse('search', { items: [], next_page_params: null }, { queryParams: { q: blockHeight } });
const component = await render(<SearchResults/>, { hooksConfig });
await expect(component.locator('main')).toHaveScreenshot();
});
test('with results +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse(
'search',
{ items: [ { ...searchMock.token1, name: '1234567890123456789' } ], next_page_params: null },
{ queryParams: { q: blockHeight } },
);
const component = await render(<SearchResults/>, { hooksConfig });
await expect(component.locator('main')).toHaveScreenshot();
});
});
...@@ -3,7 +3,10 @@ import { useRouter } from 'next/router'; ...@@ -3,7 +3,10 @@ import { useRouter } from 'next/router';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import React from 'react'; import React from 'react';
import type { SearchResultItem } from 'types/client/search';
import config from 'configs/app'; import config from 'configs/app';
import * as regexp from 'lib/regexp';
import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps'; import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps';
import SearchResultListItem from 'ui/searchResults/SearchResultListItem'; import SearchResultListItem from 'ui/searchResults/SearchResultListItem';
import SearchResultsInput from 'ui/searchResults/SearchResultsInput'; import SearchResultsInput from 'ui/searchResults/SearchResultsInput';
...@@ -15,10 +18,12 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -15,10 +18,12 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import * as Layout from 'ui/shared/layout/components'; import * as Layout from 'ui/shared/layout/components';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import type { SearchResultAppItem } from 'ui/shared/search/utils';
import Thead from 'ui/shared/TheadSticky'; import Thead from 'ui/shared/TheadSticky';
import HeaderAlert from 'ui/snippets/header/HeaderAlert'; import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderDesktop from 'ui/snippets/header/HeaderDesktop'; import HeaderDesktop from 'ui/snippets/header/HeaderDesktop';
import HeaderMobile from 'ui/snippets/header/HeaderMobile'; import HeaderMobile from 'ui/snippets/header/HeaderMobile';
import SearchBarSuggestBlockCountdown from 'ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestBlockCountdown';
import useSearchQuery from 'ui/snippets/searchBar/useSearchQuery'; import useSearchQuery from 'ui/snippets/searchBar/useSearchQuery';
const SearchResultsPageContent = () => { const SearchResultsPageContent = () => {
...@@ -77,40 +82,54 @@ const SearchResultsPageContent = () => { ...@@ -77,40 +82,54 @@ const SearchResultsPageContent = () => {
event.preventDefault(); event.preventDefault();
}, [ ]); }, [ ]);
const displayedItems = (data?.items || []).filter((item) => { const displayedItems: Array<SearchResultItem | SearchResultAppItem> = React.useMemo(() => {
if (!config.features.userOps.isEnabled && item.type === 'user_operation') { const apiData = (data?.items || []).filter((item) => {
return false; if (!config.features.userOps.isEnabled && item.type === 'user_operation') {
} return false;
if (!config.features.dataAvailability.isEnabled && item.type === 'blob') { }
return false; if (!config.features.dataAvailability.isEnabled && item.type === 'blob') {
} return false;
if (!config.features.nameService.isEnabled && item.type === 'ens_domain') { }
return false; if (!config.features.nameService.isEnabled && item.type === 'ens_domain') {
} return false;
return true; }
}); return true;
});
const futureBlockItem = !isPlaceholderData &&
pagination.page === 1 &&
!data?.next_page_params &&
apiData.length > 0 &&
!apiData.some(({ type }) => type === 'block') &&
regexp.BLOCK_HEIGHT.test(debouncedSearchTerm) ?
{
type: 'block' as const,
block_type: 'block' as const,
block_number: debouncedSearchTerm,
block_hash: '',
timestamp: undefined,
} : undefined;
return [
...(pagination.page === 1 && !isPlaceholderData ? marketplaceApps.displayedApps.map((item) => ({ type: 'app' as const, app: item })) : []),
futureBlockItem,
...apiData,
].filter(Boolean);
}, [ data?.items, data?.next_page_params, isPlaceholderData, pagination.page, debouncedSearchTerm, marketplaceApps.displayedApps ]);
const content = (() => { const content = (() => {
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
const hasData = displayedItems.length || (pagination.page === 1 && marketplaceApps.displayedApps.length); if (!displayedItems.length) {
if (!hasData) {
return null; return null;
} }
return ( return (
<> <>
<Show below="lg" ssr={ false }> <Show below="lg" ssr={ false }>
{ pagination.page === 1 && marketplaceApps.displayedApps.map((item, index) => (
<SearchResultListItem
key={ 'actual_' + index }
data={{ type: 'app', app: item }}
searchTerm={ debouncedSearchTerm }
/>
)) }
{ displayedItems.map((item, index) => ( { displayedItems.map((item, index) => (
<SearchResultListItem <SearchResultListItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index } key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
...@@ -131,13 +150,6 @@ const SearchResultsPageContent = () => { ...@@ -131,13 +150,6 @@ const SearchResultsPageContent = () => {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{ pagination.page === 1 && marketplaceApps.displayedApps.map((item, index) => (
<SearchResultTableItem
key={ 'actual_' + index }
data={{ type: 'app', app: item }}
searchTerm={ debouncedSearchTerm }
/>
)) }
{ displayedItems.map((item, index) => ( { displayedItems.map((item, index) => (
<SearchResultTableItem <SearchResultTableItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index } key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
...@@ -158,20 +170,24 @@ const SearchResultsPageContent = () => { ...@@ -158,20 +170,24 @@ const SearchResultsPageContent = () => {
return null; return null;
} }
const resultsCount = pagination.page === 1 && !data?.next_page_params ? (displayedItems.length || 0) + marketplaceApps.displayedApps.length : '50+'; const resultsCount = pagination.page === 1 && !data?.next_page_params ? displayedItems.length : '50+';
const text = isPlaceholderData && pagination.page === 1 ? ( const text = isPlaceholderData && pagination.page === 1 ? (
<Skeleton h={ 6 } w="280px" borderRadius="full" mb={ pagination.isVisible ? 0 : 6 }/> <Skeleton h={ 6 } w="280px" borderRadius="full" mb={ pagination.isVisible ? 0 : 6 }/>
) : ( ) : (
( (
<Box mb={ pagination.isVisible ? 0 : 6 } lineHeight="32px"> <>
<span>Found </span> <Box mb={ pagination.isVisible ? 0 : 6 } lineHeight="32px">
<chakra.span fontWeight={ 700 }> <span>Found </span>
{ resultsCount } <chakra.span fontWeight={ 700 }>
</chakra.span> { resultsCount }
<span> matching result{ (((displayedItems.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for </span> </chakra.span>
<chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span> <span> matching result{ (((displayedItems.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for </span>
</Box> <chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span>
</Box>
{ resultsCount === 0 && regexp.BLOCK_HEIGHT.test(debouncedSearchTerm) &&
<SearchBarSuggestBlockCountdown blockHeight={ debouncedSearchTerm } mt={ -4 }/> }
</>
) )
); );
...@@ -217,7 +233,7 @@ const SearchResultsPageContent = () => { ...@@ -217,7 +233,7 @@ const SearchResultsPageContent = () => {
<HeaderAlert/> <HeaderAlert/>
<HeaderDesktop renderSearchBar={ renderSearchBar }/> <HeaderDesktop renderSearchBar={ renderSearchBar }/>
<AppErrorBoundary> <AppErrorBoundary>
<Layout.Content> <Layout.Content flexGrow={ 0 }>
{ pageContent } { pageContent }
</Layout.Content> </Layout.Content>
</AppErrorBoundary> </AppErrorBoundary>
......
...@@ -2,7 +2,7 @@ import { chakra, Flex, Grid, Image, Box, Text, Skeleton, useColorMode, Tag } fro ...@@ -2,7 +2,7 @@ import { chakra, Flex, Grid, Image, Box, Text, Skeleton, useColorMode, Tag } fro
import React from 'react'; import React from 'react';
import xss from 'xss'; import xss from 'xss';
import type { SearchResultItem } from 'types/api/search'; import type { SearchResultItem } from 'types/client/search';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -162,23 +162,29 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -162,23 +162,29 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
case 'block': { case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase(); const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
const isFutureBlock = data.timestamp === undefined;
const href = isFutureBlock ?
route({ pathname: '/block/countdown/[height]', query: { height: String(data.block_number) } }) :
route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: data.block_hash ?? String(data.block_number) } });
return ( return (
<BlockEntity.Container> <BlockEntity.Container>
<BlockEntity.Icon/> <BlockEntity.Icon isLoading={ isLoading }/>
<BlockEntity.Link <BlockEntity.Link
hash={ data.block_hash } href={ href }
number={ Number(data.block_number) }
onClick={ handleLinkClick } onClick={ handleLinkClick }
isLoading={ isLoading }
> >
<BlockEntity.Content <BlockEntity.Content
asProp={ shouldHighlightHash ? 'span' : 'mark' } asProp={ shouldHighlightHash ? 'span' : 'mark' }
number={ Number(data.block_number) } number={ Number(data.block_number) }
fontSize="sm" fontSize="sm"
fontWeight={ 700 } fontWeight={ 700 }
isLoading={ isLoading }
/> />
</BlockEntity.Link> </BlockEntity.Link>
{ data.block_type === 'reorg' && <Tag ml={ 2 }>Reorg</Tag> } { data.block_type === 'reorg' && !isLoading && <Tag ml={ 2 }>Reorg</Tag> }
{ data.block_type === 'uncle' && <Tag ml={ 2 }>Uncle</Tag> } { data.block_type === 'uncle' && !isLoading && <Tag ml={ 2 }>Uncle</Tag> }
</BlockEntity.Container> </BlockEntity.Container>
); );
} }
...@@ -295,12 +301,20 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -295,12 +301,20 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
} }
case 'block': { case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase(); const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
const isFutureBlock = data.timestamp === undefined;
if (isFutureBlock) {
return <Skeleton isLoaded={ !isLoading }>Learn estimated time for this block to be created.</Skeleton>;
}
return ( return (
<> <>
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" whiteSpace="nowrap" overflow="hidden" mb={ 1 }> <Skeleton isLoaded={ !isLoading } as={ shouldHighlightHash ? 'mark' : 'span' } display="block" whiteSpace="nowrap" overflow="hidden" mb={ 1 }>
<HashStringShortenDynamic hash={ data.block_hash }/> <HashStringShortenDynamic hash={ data.block_hash }/>
</Box> </Skeleton>
<Text variant="secondary" mr={ 2 }>{ dayjs(data.timestamp).format('llll') }</Text> <Skeleton isLoaded={ !isLoading } color="text_secondary" mr={ 2 }>
<span>{ dayjs(data.timestamp).format('llll') }</span>
</Skeleton>
</> </>
); );
} }
......
...@@ -2,7 +2,7 @@ import { chakra, Tr, Td, Text, Flex, Image, Box, Skeleton, useColorMode, Tag } f ...@@ -2,7 +2,7 @@ import { chakra, Tr, Td, Text, Flex, Image, Box, Skeleton, useColorMode, Tag } f
import React from 'react'; import React from 'react';
import xss from 'xss'; import xss from 'xss';
import type { SearchResultItem } from 'types/api/search'; import type { SearchResultItem } from 'types/client/search';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -235,16 +235,20 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -235,16 +235,20 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
case 'block': { case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase(); const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
const isFutureBlock = data.timestamp === undefined && !isLoading;
const href = isFutureBlock ?
route({ pathname: '/block/countdown/[height]', query: { height: String(data.block_number) } }) :
route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: data.block_hash ?? String(data.block_number) } });
return ( return (
<> <>
<Td fontSize="sm"> <Td fontSize="sm">
<BlockEntity.Container> <BlockEntity.Container>
<BlockEntity.Icon/> <BlockEntity.Icon isLoading={ isLoading }/>
<BlockEntity.Link <BlockEntity.Link
hash={ data.block_hash } href={ href }
number={ Number(data.block_number) }
onClick={ handleLinkClick } onClick={ handleLinkClick }
isLoading={ isLoading }
> >
<BlockEntity.Content <BlockEntity.Content
asProp={ shouldHighlightHash ? 'span' : 'mark' } asProp={ shouldHighlightHash ? 'span' : 'mark' }
...@@ -252,22 +256,31 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -252,22 +256,31 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
fontSize="sm" fontSize="sm"
lineHeight={ 5 } lineHeight={ 5 }
fontWeight={ 700 } fontWeight={ 700 }
isLoading={ isLoading }
/> />
</BlockEntity.Link> </BlockEntity.Link>
</BlockEntity.Container> </BlockEntity.Container>
</Td> </Td>
<Td fontSize="sm" verticalAlign="middle"> <Td fontSize="sm" verticalAlign="middle" colSpan={ isFutureBlock ? 2 : 1 }>
<Flex columnGap={ 2 } alignItems="center"> { isFutureBlock ? (
{ data.block_type === 'reorg' && <Tag flexShrink={ 0 }>Reorg</Tag> } <Skeleton isLoaded={ !isLoading }>Learn estimated time for this block to be created.</Skeleton>
{ data.block_type === 'uncle' && <Tag flexShrink={ 0 }>Uncle</Tag> } ) : (
<Box overflow="hidden" whiteSpace="nowrap" as={ shouldHighlightHash ? 'mark' : 'span' } display="block"> <Flex columnGap={ 2 } alignItems="center">
<HashStringShortenDynamic hash={ data.block_hash }/> { data.block_type === 'reorg' && !isLoading && <Tag flexShrink={ 0 }>Reorg</Tag> }
</Box> { data.block_type === 'uncle' && !isLoading && <Tag flexShrink={ 0 }>Uncle</Tag> }
</Flex> <Skeleton isLoaded={ !isLoading } overflow="hidden" whiteSpace="nowrap" as={ shouldHighlightHash ? 'mark' : 'span' } display="block">
</Td> <HashStringShortenDynamic hash={ data.block_hash }/>
<Td fontSize="sm" verticalAlign="middle" isNumeric> </Skeleton>
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text> </Flex>
) }
</Td> </Td>
{ !isFutureBlock && (
<Td fontSize="sm" verticalAlign="middle" isNumeric>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ dayjs(data.timestamp).format('llll') }</span>
</Skeleton>
</Td>
) }
</> </>
); );
} }
......
...@@ -6,7 +6,7 @@ import { route } from 'nextjs-routes'; ...@@ -6,7 +6,7 @@ import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components'; import * as EntityBase from 'ui/shared/entities/base/components';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'hash' | 'number'>; type LinkProps = EntityBase.LinkBaseProps & Partial<Pick<EntityProps, 'hash' | 'number'>>;
const Link = chakra((props: LinkProps) => { const Link = chakra((props: LinkProps) => {
const heightOrHash = props.hash ?? String(props.number); const heightOrHash = props.hash ?? String(props.number);
......
...@@ -6,15 +6,17 @@ import ClearButton from 'ui/shared/ClearButton'; ...@@ -6,15 +6,17 @@ import ClearButton from 'ui/shared/ClearButton';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
type Props = { type Props = {
onChange: (searchTerm: string) => void; onChange?: (searchTerm: string) => void;
className?: string; className?: string;
size?: 'xs' | 'sm' | 'md' | 'lg'; size?: 'xs' | 'sm' | 'md' | 'lg';
placeholder: string; placeholder: string;
initialValue?: string; initialValue?: string;
isLoading?: boolean; isLoading?: boolean;
type?: React.HTMLInputTypeAttribute;
name?: string;
} }
const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialValue, isLoading }: Props) => { const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialValue, isLoading, type, name }: Props) => {
const [ filterQuery, setFilterQuery ] = useState(initialValue || ''); const [ filterQuery, setFilterQuery ] = useState(initialValue || '');
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const iconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600'); const iconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
...@@ -23,12 +25,12 @@ const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialVal ...@@ -23,12 +25,12 @@ const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialVal
const { value } = event.target; const { value } = event.target;
setFilterQuery(value); setFilterQuery(value);
onChange(value); onChange?.(value);
}, [ onChange ]); }, [ onChange ]);
const handleFilterQueryClear = useCallback(() => { const handleFilterQueryClear = useCallback(() => {
setFilterQuery(''); setFilterQuery('');
onChange(''); onChange?.('');
inputRef?.current?.focus(); inputRef?.current?.focus();
}, [ onChange ]); }, [ onChange ]);
...@@ -57,6 +59,8 @@ const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialVal ...@@ -57,6 +59,8 @@ const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialVal
borderWidth="2px" borderWidth="2px"
textOverflow="ellipsis" textOverflow="ellipsis"
whiteSpace="nowrap" whiteSpace="nowrap"
type={ type }
name={ name }
/> />
{ filterQuery ? ( { filterQuery ? (
......
...@@ -8,7 +8,7 @@ interface Props { ...@@ -8,7 +8,7 @@ interface Props {
const Content = ({ children, className }: Props) => { const Content = ({ children, className }: Props) => {
return ( return (
<Box pt={{ base: 0, lg: 6 }} as="main" className={ className }> <Box pt={{ base: 0, lg: 6 }} as="main" flexGrow={ 1 } className={ className }>
{ children } { children }
</Box> </Box>
); );
......
import type { SearchResultItem } from 'types/api/search';
import type { MarketplaceAppOverview } from 'types/client/marketplace'; import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { SearchResultItem } from 'types/client/search';
import config from 'configs/app'; import config from 'configs/app';
......
...@@ -81,7 +81,7 @@ const StatsWidget = ({ ...@@ -81,7 +81,7 @@ const StatsWidget = ({
flexShrink={ 0 } flexShrink={ 0 }
/> />
) } ) }
<Box w="100%"> <Box w={{ base: '100%', lg: icon ? 'calc(100% - 48px)' : '100%' }}>
<Skeleton <Skeleton
isLoaded={ !isLoading } isLoaded={ !isLoading }
color="text_secondary" color="text_secondary"
......
...@@ -217,3 +217,25 @@ test.describe('with apps', () => { ...@@ -217,3 +217,25 @@ test.describe('with apps', () => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
}); });
}); });
test.describe('block countdown', () => {
test('no results +@mobile', async({ render, page, mockApiResponse }) => {
const apiUrl = await mockApiResponse('quick_search', [], { queryParams: { q: '1234567890' } });
await render(<SearchBar/>);
await page.getByPlaceholder(/search/i).fill('1234567890');
await page.waitForResponse(apiUrl);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
});
test('with results +@mobile', async({ render, page, mockApiResponse }) => {
const apiUrl = await mockApiResponse('quick_search', [
{ ...searchMock.token1, name: '1234567890123456789' },
], { queryParams: { q: '1234567890' } });
await render(<SearchBar/>);
await page.getByPlaceholder(/search/i).fill('1234567890');
await page.waitForResponse(apiUrl);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
});
});
...@@ -8,6 +8,7 @@ import type { SearchResultItem } from 'types/api/search'; ...@@ -8,6 +8,7 @@ import type { SearchResultItem } from 'types/api/search';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as regexp from 'lib/regexp';
import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps'; import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
...@@ -15,6 +16,7 @@ import type { ApiCategory, ItemsCategoriesMap } from 'ui/shared/search/utils'; ...@@ -15,6 +16,7 @@ import type { ApiCategory, ItemsCategoriesMap } from 'ui/shared/search/utils';
import { getItemCategory, searchCategories } from 'ui/shared/search/utils'; import { getItemCategory, searchCategories } from 'ui/shared/search/utils';
import SearchBarSuggestApp from './SearchBarSuggestApp'; import SearchBarSuggestApp from './SearchBarSuggestApp';
import SearchBarSuggestBlockCountdown from './SearchBarSuggestBlockCountdown';
import SearchBarSuggestItem from './SearchBarSuggestItem'; import SearchBarSuggestItem from './SearchBarSuggestItem';
interface Props { interface Props {
...@@ -69,7 +71,9 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props ...@@ -69,7 +71,9 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
if (!query.data && !marketplaceApps.displayedApps) { if (!query.data && !marketplaceApps.displayedApps) {
return {}; return {};
} }
const map: Partial<ItemsCategoriesMap> = {}; const map: Partial<ItemsCategoriesMap> = {};
query.data?.forEach(item => { query.data?.forEach(item => {
const cat = getItemCategory(item) as ApiCategory; const cat = getItemCategory(item) as ApiCategory;
if (cat) { if (cat) {
...@@ -80,11 +84,23 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props ...@@ -80,11 +84,23 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
} }
} }
}); });
if (marketplaceApps.displayedApps.length) { if (marketplaceApps.displayedApps.length) {
map.app = marketplaceApps.displayedApps; map.app = marketplaceApps.displayedApps;
} }
if (Object.keys(map).length > 0 && !map.block && regexp.BLOCK_HEIGHT.test(searchTerm)) {
map['block'] = [ {
type: 'block',
block_type: 'block',
block_number: searchTerm,
block_hash: '',
timestamp: undefined,
} ];
}
return map; return map;
}, [ query.data, marketplaceApps.displayedApps ]); }, [ query.data, marketplaceApps.displayedApps, searchTerm ]);
React.useEffect(() => { React.useEffect(() => {
categoriesRefs.current = Array(Object.keys(itemsGroups).length).fill('').map((_, i) => categoriesRefs.current[i] || React.createRef()); categoriesRefs.current = Array(Object.keys(itemsGroups).length).fill('').map((_, i) => categoriesRefs.current[i] || React.createRef());
...@@ -114,6 +130,10 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props ...@@ -114,6 +130,10 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]); const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
if (resultCategories.length === 0) { if (resultCategories.length === 0) {
if (regexp.BLOCK_HEIGHT.test(searchTerm)) {
return <SearchBarSuggestBlockCountdown blockHeight={ searchTerm } onClick={ onItemClick }/>;
}
return <Text>No results found.</Text>; return <Text>No results found.</Text>;
} }
......
import { Text, Flex, Grid, Tag } from '@chakra-ui/react'; import { Text, Flex, Grid, Tag } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SearchResultBlock } from 'types/api/search'; import type { SearchResultBlock } from 'types/client/search';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText'; import highlightText from 'lib/highlightText';
...@@ -17,6 +17,8 @@ interface Props { ...@@ -17,6 +17,8 @@ interface Props {
const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => { const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => {
const icon = <BlockEntity.Icon/>; const icon = <BlockEntity.Icon/>;
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase(); const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
const isFutureBlock = data.timestamp === undefined;
const blockNumber = ( const blockNumber = (
<Text <Text
fontWeight={ 700 } fontWeight={ 700 }
...@@ -27,7 +29,7 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => { ...@@ -27,7 +29,7 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => {
<span dangerouslySetInnerHTML={{ __html: highlightText(data.block_number.toString(), searchTerm) }}/> <span dangerouslySetInnerHTML={{ __html: highlightText(data.block_number.toString(), searchTerm) }}/>
</Text> </Text>
); );
const hash = ( const hash = !isFutureBlock ? (
<Text <Text
variant="secondary" variant="secondary"
overflow="hidden" overflow="hidden"
...@@ -37,8 +39,9 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => { ...@@ -37,8 +39,9 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => {
> >
<HashStringShortenDynamic hash={ data.block_hash } isTooltipDisabled/> <HashStringShortenDynamic hash={ data.block_hash } isTooltipDisabled/>
</Text> </Text>
); ) : null;
const date = dayjs(data.timestamp).format('llll'); const date = !isFutureBlock ? dayjs(data.timestamp).format('llll') : undefined;
const futureBlockText = <Text variant="secondary">Learn estimated time for this block to be created.</Text>;
if (isMobile) { if (isMobile) {
return ( return (
...@@ -50,7 +53,7 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => { ...@@ -50,7 +53,7 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => {
{ data.block_type === 'uncle' && <Tag ml="auto">Uncle</Tag> } { data.block_type === 'uncle' && <Tag ml="auto">Uncle</Tag> }
</Flex> </Flex>
{ hash } { hash }
<Text variant="secondary">{ date }</Text> { isFutureBlock ? futureBlockText : <Text variant="secondary">{ date }</Text> }
</> </>
); );
} }
...@@ -64,9 +67,9 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => { ...@@ -64,9 +67,9 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => {
<Flex columnGap={ 3 } minW={ 0 } alignItems="center"> <Flex columnGap={ 3 } minW={ 0 } alignItems="center">
{ data.block_type === 'reorg' && <Tag flexShrink={ 0 }>Reorg</Tag> } { data.block_type === 'reorg' && <Tag flexShrink={ 0 }>Reorg</Tag> }
{ data.block_type === 'uncle' && <Tag flexShrink={ 0 }>Uncle</Tag> } { data.block_type === 'uncle' && <Tag flexShrink={ 0 }>Uncle</Tag> }
{ hash } { isFutureBlock ? futureBlockText : hash }
</Flex> </Flex>
<Text variant="secondary" textAlign="end">{ date }</Text> { date && <Text variant="secondary" textAlign="end">{ date }</Text> }
</Grid> </Grid>
); );
}; };
......
import { chakra, Box } from '@chakra-ui/react';
import React from 'react';
import { route } from 'nextjs-routes';
import LinkInternal from 'ui/shared/links/LinkInternal';
interface Props {
blockHeight: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
className?: string;
}
const SearchBarSuggestBlockCountdown = ({ blockHeight, onClick, className }: Props) => {
return (
<Box className={ className }>
<span>Learn </span>
<LinkInternal href={ route({ pathname: '/block/countdown/[height]', query: { height: blockHeight } }) } onClick={ onClick }>
estimated time for this block
</LinkInternal>
<span> to be created.</span>
</Box>
);
};
export default React.memo(chakra(SearchBarSuggestBlockCountdown));
...@@ -2,7 +2,7 @@ import type { LinkProps as NextLinkProps } from 'next/link'; ...@@ -2,7 +2,7 @@ import type { LinkProps as NextLinkProps } from 'next/link';
import NextLink from 'next/link'; import NextLink from 'next/link';
import React from 'react'; import React from 'react';
import type { SearchResultItem } from 'types/api/search'; import type { SearchResultItem } from 'types/client/search';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -39,6 +39,11 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -39,6 +39,11 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
return route({ pathname: '/tx/[hash]', query: { hash: data.tx_hash } }); return route({ pathname: '/tx/[hash]', query: { hash: data.tx_hash } });
} }
case 'block': { case 'block': {
const isFutureBlock = data.timestamp === undefined;
if (isFutureBlock) {
return route({ pathname: '/block/countdown/[height]', query: { height: String(data.block_number) } });
}
return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } }); return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } });
} }
case 'user_operation': { case 'user_operation': {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment