Commit e2c75d14 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #1037 from blockscout/search-info

Search info
parents 4e370eb7 45a9f363
...@@ -7,6 +7,8 @@ import relativeTime from 'dayjs/plugin/relativeTime'; ...@@ -7,6 +7,8 @@ import relativeTime from 'dayjs/plugin/relativeTime';
import updateLocale from 'dayjs/plugin/updateLocale'; import updateLocale from 'dayjs/plugin/updateLocale';
import weekOfYear from 'dayjs/plugin/weekOfYear'; import weekOfYear from 'dayjs/plugin/weekOfYear';
import { nbsp } from 'lib/html-entities';
const relativeTimeConfig = { const relativeTimeConfig = {
thresholds: [ thresholds: [
{ l: 's', r: 1 }, { l: 's', r: 1 },
...@@ -33,7 +35,7 @@ dayjs.extend(minMax); ...@@ -33,7 +35,7 @@ dayjs.extend(minMax);
dayjs.updateLocale('en', { dayjs.updateLocale('en', {
formats: { formats: {
LLLL: 'MMMM-DD-YYYY HH:mm:ss A Z UTC', llll: `MMM DD YYYY HH:mm:ss A (Z${ nbsp }UTC)`,
}, },
relativeTime: { relativeTime: {
s: 'a sec', s: 'a sec',
......
...@@ -8,6 +8,10 @@ export const token1: SearchResultToken = { ...@@ -8,6 +8,10 @@ export const token1: SearchResultToken = {
token_url: '/token/0x377c5F2B300B25a534d4639177873b7fEAA56d4B', token_url: '/token/0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
type: 'token' as const, type: 'token' as const,
icon_url: 'http://localhost:3000/token-icon.png', icon_url: 'http://localhost:3000/token-icon.png',
token_type: 'ERC-721',
total_supply: '10000001',
exchange_rate: null,
is_smart_contract_verified: true,
}; };
export const token2: SearchResultToken = { export const token2: SearchResultToken = {
...@@ -18,12 +22,17 @@ export const token2: SearchResultToken = { ...@@ -18,12 +22,17 @@ export const token2: SearchResultToken = {
token_url: '/token/0xC35Cc7223B0175245E9964f2E3119c261E8e21F9', token_url: '/token/0xC35Cc7223B0175245E9964f2E3119c261E8e21F9',
type: 'token' as const, type: 'token' as const,
icon_url: null, icon_url: null,
token_type: 'ERC-20',
total_supply: '10000001',
exchange_rate: '1.11',
is_smart_contract_verified: false,
}; };
export const block1: SearchResultBlock = { export const block1: SearchResultBlock = {
block_hash: '0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd1', block_hash: '0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd1',
block_number: 8198536, block_number: 8198536,
type: 'block' as const, type: 'block' as const,
timestamp: '2022-12-11T17:55:20Z',
url: '/block/0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd1', url: '/block/0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd1',
}; };
...@@ -31,6 +40,7 @@ export const address1: SearchResultAddressOrContract = { ...@@ -31,6 +40,7 @@ export const address1: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: null, name: null,
type: 'address' as const, type: 'address' as const,
is_smart_contract_verified: false,
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
}; };
...@@ -38,6 +48,7 @@ export const contract1: SearchResultAddressOrContract = { ...@@ -38,6 +48,7 @@ export const contract1: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: 'Unknown contract in this network', name: 'Unknown contract in this network',
type: 'contract' as const, type: 'contract' as const,
is_smart_contract_verified: true,
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
}; };
...@@ -45,12 +56,14 @@ export const label1: SearchResultLabel = { ...@@ -45,12 +56,14 @@ export const label1: SearchResultLabel = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: 'utko', name: 'utko',
type: 'label' as const, type: 'label' as const,
is_smart_contract_verified: true,
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
}; };
export const tx1: SearchResultTx = { export const tx1: SearchResultTx = {
tx_hash: '0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd', tx_hash: '0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
type: 'transaction' as const, type: 'transaction' as const,
timestamp: '2022-12-11T17:55:20Z',
url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd', url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
}; };
......
...@@ -10,6 +10,10 @@ export const SEARCH_RESULT_ITEM: SearchResultItem = { ...@@ -10,6 +10,10 @@ export const SEARCH_RESULT_ITEM: SearchResultItem = {
token_url: '/token/0x3714A8C7824B22271550894f7555f0a672f97809', token_url: '/token/0x3714A8C7824B22271550894f7555f0a672f97809',
type: 'token', type: 'token',
icon_url: null, icon_url: null,
is_smart_contract_verified: false,
exchange_rate: '1.11',
total_supply: null,
token_type: 'ERC-20',
}; };
export const SEARCH_RESULT_NEXT_PAGE_PARAMS: SearchResult['next_page_params'] = { export const SEARCH_RESULT_NEXT_PAGE_PARAMS: SearchResult['next_page_params'] = {
......
import type { TokenType } from 'types/api/token';
export type SearchResultType = 'token' | 'address' | 'block' | 'transaction' | 'contract'; export type SearchResultType = 'token' | 'address' | 'block' | 'transaction' | 'contract';
export interface SearchResultToken { export interface SearchResultToken {
...@@ -8,12 +10,17 @@ export interface SearchResultToken { ...@@ -8,12 +10,17 @@ export interface SearchResultToken {
token_url: string; token_url: string;
address_url: string; address_url: string;
icon_url: string | null; icon_url: string | null;
token_type: TokenType;
exchange_rate: string | null;
total_supply: string | null;
is_smart_contract_verified: boolean;
} }
export interface SearchResultAddressOrContract { export interface SearchResultAddressOrContract {
type: 'address' | 'contract'; type: 'address' | 'contract';
name: string | null; name: string | null;
address: string; address: string;
is_smart_contract_verified: boolean;
url?: string; // not used by the frontend, we build the url ourselves url?: string; // not used by the frontend, we build the url ourselves
} }
...@@ -21,6 +28,7 @@ export interface SearchResultLabel { ...@@ -21,6 +28,7 @@ export interface SearchResultLabel {
type: 'label'; type: 'label';
address: string; address: string;
name: string; name: string;
is_smart_contract_verified: boolean;
url?: string; // not used by the frontend, we build the url ourselves url?: string; // not used by the frontend, we build the url ourselves
} }
...@@ -28,12 +36,14 @@ export interface SearchResultBlock { ...@@ -28,12 +36,14 @@ export interface SearchResultBlock {
type: 'block'; type: 'block';
block_number: number | string; block_number: number | string;
block_hash: string; block_hash: string;
timestamp: string;
url?: string; // not used by the frontend, we build the url ourselves url?: string; // not used by the frontend, we build the url ourselves
} }
export interface SearchResultTx { export interface SearchResultTx {
type: 'transaction'; type: 'transaction';
tx_hash: string; tx_hash: string;
timestamp: string;
url?: string; // not used by the frontend, we build the url ourselves url?: string; // not used by the frontend, we build the url ourselves
} }
......
...@@ -202,7 +202,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => { ...@@ -202,7 +202,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
<InfoItem label="Optimization enabled" value={ data.optimization_enabled ? 'true' : 'false' } isLoading={ isPlaceholderData }/> } <InfoItem label="Optimization enabled" value={ data.optimization_enabled ? 'true' : 'false' } isLoading={ isPlaceholderData }/> }
{ data.optimization_runs && <InfoItem label="Optimization runs" value={ String(data.optimization_runs) } isLoading={ isPlaceholderData }/> } { data.optimization_runs && <InfoItem label="Optimization runs" value={ String(data.optimization_runs) } isLoading={ isPlaceholderData }/> }
{ data.verified_at && { data.verified_at &&
<InfoItem label="Verified at" value={ dayjs(data.verified_at).format('LLLL') } wordBreak="break-word" isLoading={ isPlaceholderData }/> } <InfoItem label="Verified at" value={ dayjs(data.verified_at).format('llll') } wordBreak="break-word" isLoading={ isPlaceholderData }/> }
{ data.file_path && <InfoItem label="Contract file path" value={ data.file_path } wordBreak="break-word" isLoading={ isPlaceholderData }/> } { data.file_path && <InfoItem label="Contract file path" value={ data.file_path } wordBreak="break-word" isLoading={ isPlaceholderData }/> }
</Grid> </Grid>
) } ) }
......
...@@ -166,7 +166,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -166,7 +166,7 @@ const BlockDetails = ({ query }: Props) => {
</Skeleton> </Skeleton>
<TextSeparator/> <TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal"> <Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('LLLL') } { dayjs(data.timestamp).format('llll') }
</Skeleton> </Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
......
...@@ -54,7 +54,7 @@ const IndexingAlertIntTxs = ({ className }: { className?: string }) => { ...@@ -54,7 +54,7 @@ const IndexingAlertIntTxs = ({ className }: { className?: string }) => {
<Text fontSize="xs" color={ hintTextcolor }> <Text fontSize="xs" color={ hintTextcolor }>
{ data.indexed_internal_transactions_ratio && { data.indexed_internal_transactions_ratio &&
`${ Math.floor(Number(data.indexed_internal_transactions_ratio) * 100) }% Blocks With Internal Transactions Indexed${ nbsp }${ ndash } ` } `${ Math.floor(Number(data.indexed_internal_transactions_ratio) * 100) }% Blocks With Internal Transactions Indexed${ nbsp }${ ndash } ` }
We{ apos }re indexing this chain right now. Some of the counts may be inaccurate.` We{ apos }re indexing this chain right now. Some of the counts may be inaccurate.
</Text> </Text>
); );
......
...@@ -81,8 +81,9 @@ const SearchResultsPageContent = () => { ...@@ -81,8 +81,9 @@ const SearchResultsPageContent = () => {
<Table variant="simple" size="md" fontWeight={ 500 }> <Table variant="simple" size="md" fontWeight={ 500 }>
<Thead top={ pagination.isVisible ? 80 : 0 }> <Thead top={ pagination.isVisible ? 80 : 0 }>
<Tr> <Tr>
<Th width="50%">Search Result</Th> <Th width="30%">Search Result</Th>
<Th width="50%"/> <Th width="35%"/>
<Th width="35%" pr={ 10 }/>
<Th width="150px">Category</Th> <Th width="150px">Category</Th>
</Tr> </Tr>
</Thead> </Thead>
......
import { Flex, Icon, Box, chakra, Skeleton } from '@chakra-ui/react'; import { Flex, Grid, Icon, Box, Text, chakra, Skeleton } from '@chakra-ui/react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import React from 'react'; import React from 'react';
...@@ -6,7 +6,9 @@ import type { SearchResultItem } from 'types/api/search'; ...@@ -6,7 +6,9 @@ import type { SearchResultItem } from 'types/api/search';
import blockIcon from 'icons/block.svg'; import blockIcon from 'icons/block.svg';
import labelIcon from 'icons/publictags.svg'; import labelIcon from 'icons/publictags.svg';
import iconSuccess from 'icons/status/success.svg';
import txIcon from 'icons/transactions.svg'; import txIcon from 'icons/transactions.svg';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText'; import highlightText from 'lib/highlightText';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
...@@ -16,6 +18,7 @@ import AddressLink from 'ui/shared/address/AddressLink'; ...@@ -16,6 +18,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import { getItemCategory, searchItemTitles } from 'ui/shared/search/utils';
import TokenLogo from 'ui/shared/TokenLogo'; import TokenLogo from 'ui/shared/TokenLogo';
interface Props { interface Props {
...@@ -41,7 +44,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -41,7 +44,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
const name = data.name + (data.symbol ? ` (${ data.symbol })` : ''); const name = data.name + (data.symbol ? ` (${ data.symbol })` : '');
return ( return (
<Flex alignItems="flex-start" overflow="hidden"> <Flex alignItems="flex-start" flexGrow={ 1 } overflow="hidden">
<TokenLogo boxSize={ 6 } data={ data } flexShrink={ 0 } isLoading={ isLoading }/> <TokenLogo boxSize={ 6 } data={ data } flexShrink={ 0 } isLoading={ isLoading }/>
<LinkInternal <LinkInternal
ml={ 2 } ml={ 2 }
...@@ -50,6 +53,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -50,6 +53,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
wordBreak="break-all" wordBreak="break-all"
isLoading={ isLoading } isLoading={ isLoading }
onClick={ handleLinkClick } onClick={ handleLinkClick }
flexGrow={ 1 }
overflow="hidden" overflow="hidden"
> >
<Skeleton <Skeleton
...@@ -73,6 +77,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -73,6 +77,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" whiteSpace="nowrap" overflow="hidden"> <Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" whiteSpace="nowrap" overflow="hidden">
<AddressLink type="address" hash={ data.address } fontWeight={ 700 } display="block" w="100%" onClick={ handleLinkClick }/> <AddressLink type="address" hash={ data.address } fontWeight={ 700 } display="block" w="100%" onClick={ handleLinkClick }/>
</Box> </Box>
{ data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/> }
</Address> </Address>
); );
} }
...@@ -82,7 +87,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -82,7 +87,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
<Flex alignItems="flex-start"> <Flex alignItems="flex-start">
<Icon as={ labelIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/> <Icon as={ labelIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<LinkInternal <LinkInternal
ml={ 2 }
href={ route({ pathname: '/address/[hash]', query: { hash: data.address } }) } href={ route({ pathname: '/address/[hash]', query: { hash: data.address } }) }
fontWeight={ 700 } fontWeight={ 700 }
wordBreak="break-all" wordBreak="break-all"
...@@ -104,6 +108,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -104,6 +108,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
fontWeight={ 700 } fontWeight={ 700 }
href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } }) } href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } }) }
onClick={ handleLinkClick } onClick={ handleLinkClick }
mr={ 4 }
> >
<Box as={ shouldHighlightHash ? 'span' : 'mark' }>{ data.block_number }</Box> <Box as={ shouldHighlightHash ? 'span' : 'mark' }>{ data.block_number }</Box>
</LinkInternal> </LinkInternal>
...@@ -127,18 +132,48 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -127,18 +132,48 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
const secondRow = (() => { const secondRow = (() => {
switch (data.type) { switch (data.type) {
case 'token': { case 'token': {
const templateCols = `1fr
${ (data.token_type === 'ERC-20' && data.exchange_rate) || (data.token_type !== 'ERC-20' && data.total_supply) ? ' auto' : '' }`;
return ( return (
<Skeleton isLoaded={ !isLoading }> <Grid templateColumns={ templateCols } alignItems="center" gap={ 2 }>
<HashStringShortenDynamic hash={ data.address }/> <Skeleton isLoaded={ !isLoading } overflow="hidden" display="flex" alignItems="center">
</Skeleton> <Text variant="secondary" whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>
</Text>
{ data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/> }
</Skeleton>
<Text overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis" fontWeight={ 700 }>
{ data.token_type === 'ERC-20' && data.exchange_rate && `$${ Number(data.exchange_rate).toLocaleString() }` }
{ data.token_type !== 'ERC-20' && data.total_supply && `Items ${ Number(data.total_supply).toLocaleString() }` }
</Text>
</Grid>
); );
} }
case 'block': { case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase(); const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return ( return (
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" w="100%" whiteSpace="nowrap" overflow="hidden"> <>
<HashStringShortenDynamic hash={ data.block_hash }/> <Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" whiteSpace="nowrap" overflow="hidden" mb={ 1 }>
</Box> <HashStringShortenDynamic hash={ data.block_hash }/>
</Box>
<Text variant="secondary" mr={ 2 }>{ dayjs(data.timestamp).format('llll') }</Text>
</>
);
}
case 'transaction': {
return (
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
);
}
case 'label': {
return (
<Flex alignItems="center">
<Box overflow="hidden">
<HashStringShortenDynamic hash={ data.address }/>
</Box>
{ data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/> }
</Flex>
); );
} }
case 'contract': case 'contract':
...@@ -152,12 +187,14 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -152,12 +187,14 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
} }
})(); })();
const category = getItemCategory(data);
return ( return (
<ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }> <ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }>
<Flex justifyContent="space-between" w="100%" overflow="hidden" lineHeight={ 6 }> <Flex justifyContent="space-between" w="100%" overflow="hidden" lineHeight={ 6 }>
{ firstRow } { firstRow }
<Skeleton isLoaded={ !isLoading } color="text_secondary" ml={ 8 } textTransform="capitalize"> <Skeleton isLoaded={ !isLoading } color="text_secondary" ml={ 8 } textTransform="capitalize">
<span>{ data.type }</span> <span>{ category ? searchItemTitles[category].itemTitleShort : '' }</span>
</Skeleton> </Skeleton>
</Flex> </Flex>
{ Boolean(secondRow) && ( { Boolean(secondRow) && (
......
import { Tr, Td, Flex, Icon, Box, Skeleton } from '@chakra-ui/react'; import { chakra, Tr, Td, Text, Flex, Icon, Box, Skeleton } from '@chakra-ui/react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import React from 'react'; import React from 'react';
...@@ -6,7 +6,9 @@ import type { SearchResultItem } from 'types/api/search'; ...@@ -6,7 +6,9 @@ import type { SearchResultItem } from 'types/api/search';
import blockIcon from 'icons/block.svg'; import blockIcon from 'icons/block.svg';
import labelIcon from 'icons/publictags.svg'; import labelIcon from 'icons/publictags.svg';
import iconSuccess from 'icons/status/success.svg';
import txIcon from 'icons/transactions.svg'; import txIcon from 'icons/transactions.svg';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText'; import highlightText from 'lib/highlightText';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
...@@ -15,6 +17,7 @@ import AddressIcon from 'ui/shared/address/AddressIcon'; ...@@ -15,6 +17,7 @@ import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import { getItemCategory, searchItemTitles } from 'ui/shared/search/utils';
import TokenLogo from 'ui/shared/TokenLogo'; import TokenLogo from 'ui/shared/TokenLogo';
interface Props { interface Props {
...@@ -64,8 +67,19 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -64,8 +67,19 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
</Flex> </Flex>
</Td> </Td>
<Td fontSize="sm" verticalAlign="middle"> <Td fontSize="sm" verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } whiteSpace="nowrap" overflow="hidden" display="flex" alignItems="center">
<Box overflow="hidden" whiteSpace="nowrap" w={ data.is_smart_contract_verified ? 'calc(100%-28px)' : 'unset' }>
<HashStringShortenDynamic hash={ data.address }/>
</Box>
{ data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/> }
</Skeleton>
</Td>
<Td fontSize="sm" verticalAlign="middle" isNumeric>
<Skeleton isLoaded={ !isLoading } whiteSpace="nowrap" overflow="hidden"> <Skeleton isLoaded={ !isLoading } whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ data.address }/> <Text overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis" fontWeight={ 700 }>
{ data.token_type === 'ERC-20' && data.exchange_rate && `$${ Number(data.exchange_rate).toLocaleString() }` }
{ data.token_type !== 'ERC-20' && data.total_supply && `Items ${ Number(data.total_supply).toLocaleString() }` }
</Text>
</Skeleton> </Skeleton>
</Td> </Td>
</> </>
...@@ -93,9 +107,10 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -93,9 +107,10 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
<HashStringShortenDynamic hash={ data.address }/> <HashStringShortenDynamic hash={ data.address }/>
</Box> </Box>
</LinkInternal> </LinkInternal>
{ data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/> }
</Flex> </Flex>
</Td> </Td>
<Td fontSize="sm" verticalAlign="middle"> <Td colSpan={ 2 } fontSize="sm" verticalAlign="middle">
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? data.name : highlightText(data.name, searchTerm) }}/> <span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? data.name : highlightText(data.name, searchTerm) }}/>
</Td> </Td>
</> </>
...@@ -103,12 +118,13 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -103,12 +118,13 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
} }
return ( return (
<Td colSpan={ 2 } fontSize="sm"> <Td colSpan={ 3 } fontSize="sm">
<Address> <Address>
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/> <AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<mark> <mark>
<AddressLink hash={ data.address } type="address" fontWeight={ 700 } onClick={ handleLinkClick }/> <AddressLink hash={ data.address } type="address" fontWeight={ 700 } onClick={ handleLinkClick }/>
</mark> </mark>
{ data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/> }
</Address> </Address>
</Td> </Td>
); );
...@@ -121,7 +137,6 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -121,7 +137,6 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
<Flex alignItems="center"> <Flex alignItems="center">
<Icon as={ labelIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/> <Icon as={ labelIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<LinkInternal <LinkInternal
ml={ 2 }
href={ route({ pathname: '/address/[hash]', query: { hash: data.address } }) } href={ route({ pathname: '/address/[hash]', query: { hash: data.address } }) }
fontWeight={ 700 } fontWeight={ 700 }
wordBreak="break-all" wordBreak="break-all"
...@@ -133,10 +148,14 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -133,10 +148,14 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
</Flex> </Flex>
</Td> </Td>
<Td fontSize="sm" verticalAlign="middle"> <Td fontSize="sm" verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } whiteSpace="nowrap" overflow="hidden"> <Flex alignItems="center" overflow="hidden">
<HashStringShortenDynamic hash={ data.address }/> <Box overflow="hidden" whiteSpace="nowrap" w={ data.is_smart_contract_verified ? 'calc(100%-28px)' : 'unset' }>
</Skeleton> <HashStringShortenDynamic hash={ data.address }/>
</Box>
{ data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/> }
</Flex>
</Td> </Td>
<Td></Td>
</> </>
); );
} }
...@@ -163,31 +182,41 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -163,31 +182,41 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
<HashStringShortenDynamic hash={ data.block_hash }/> <HashStringShortenDynamic hash={ data.block_hash }/>
</Box> </Box>
</Td> </Td>
<Td fontSize="sm" verticalAlign="middle" isNumeric>
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
</Td>
</> </>
); );
} }
case 'transaction': { case 'transaction': {
return ( return (
<Td colSpan={ 2 } fontSize="sm"> <>
<Flex alignItems="center"> <Td colSpan={ 2 } fontSize="sm">
<Icon as={ txIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/> <Flex alignItems="center">
<mark> <Icon as={ txIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<AddressLink hash={ data.tx_hash } type="transaction" fontWeight={ 700 } onClick={ handleLinkClick }/> <chakra.mark overflow="hidden">
</mark> <AddressLink display="block" hash={ data.tx_hash } type="transaction" fontWeight={ 700 } onClick={ handleLinkClick } truncation="dynamic"/>
</Flex> </chakra.mark>
</Td> </Flex>
</Td>
<Td fontSize="sm" verticalAlign="middle" isNumeric>
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
</Td>
</>
); );
} }
} }
})(); })();
const category = getItemCategory(data);
return ( return (
<Tr> <Tr>
{ content } { content }
<Td fontSize="sm" textTransform="capitalize" verticalAlign="middle"> <Td fontSize="sm" textTransform="capitalize" verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"> <Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block">
<span>{ data.type }</span> <span>{ category ? searchItemTitles[category].itemTitle : '' }</span>
</Skeleton> </Skeleton>
</Td> </Td>
</Tr> </Tr>
......
import type { SearchResultItem } from 'types/api/search';
export type Category = 'token' | 'nft' | 'address' | 'app' | 'public_tag' | 'transaction' | 'block';
export const searchCategories: Array<{id: Category; title: string }> = [
{ id: 'token', title: 'Tokens (ERC-20)' },
{ id: 'nft', title: 'NFTs (ERC-721 & 1155)' },
{ id: 'address', title: 'Addresses' },
{ id: 'app', title: 'Apps' },
{ id: 'public_tag', title: 'Public tags' },
{ id: 'transaction', title: 'Transactions' },
{ id: 'block', title: 'Blocks' },
];
export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleShort: string }> = {
token: { itemTitle: 'Token', itemTitleShort: 'Token' },
nft: { itemTitle: 'NFT', itemTitleShort: 'NFT' },
address: { itemTitle: 'Address', itemTitleShort: 'Address' },
app: { itemTitle: 'App', itemTitleShort: 'App' },
public_tag: { itemTitle: 'Public tag', itemTitleShort: 'Tag' },
transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' },
block: { itemTitle: 'Block', itemTitleShort: 'Block' },
};
export function getItemCategory(item: SearchResultItem): Category | undefined {
switch (item.type) {
case 'address':
case 'contract': {
return 'address';
}
case 'token': {
if (item.token_type === 'ERC-20') {
return 'token';
}
return 'nft';
}
case 'block': {
return 'block';
}
case 'label': {
return 'public_tag';
}
case 'transaction': {
return 'transaction';
}
}
}
...@@ -28,7 +28,7 @@ test.beforeEach(async({ page }) => { ...@@ -28,7 +28,7 @@ test.beforeEach(async({ page }) => {
}); });
}); });
test('search by name +@mobile +@dark-mode', async({ mount, page }) => { test('search by token name +@mobile +@dark-mode', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o'; const API_URL = buildApiUrl('search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({ await page.route(API_URL, (route) => route.fulfill({
status: 200, status: 200,
...@@ -36,6 +36,27 @@ test('search by name +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -36,6 +36,27 @@ test('search by name +@mobile +@dark-mode', async({ mount, page }) => {
items: [ items: [
searchMock.token1, searchMock.token1,
searchMock.token2, searchMock.token2,
],
}),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
});
test('search by contract name +@mobile +@dark-mode', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.contract1, searchMock.contract1,
], ],
}), }),
...@@ -188,24 +209,51 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -188,24 +209,51 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
}); });
test('search with simple match', async({ mount, page }) => { test('search with view all link', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + `?q=${ searchMock.token2.name }`; const API_URL = buildApiUrl('search') + '?q=o';
const API_CHECK_REDIRECT_URL = buildApiUrl('search_check_redirect') + `?q=${ searchMock.token2.name }`;
await page.route(API_URL, (route) => route.fulfill({ await page.route(API_URL, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify({ body: JSON.stringify({
items: [ items: [
searchMock.token1,
searchMock.token2, searchMock.token2,
searchMock.contract1,
], ],
next_page_params: { foo: 'bar' },
}), }),
})); }));
await page.route(API_CHECK_REDIRECT_URL, (route) => route.fulfill({
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
});
test('scroll suggest to category', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify({ body: JSON.stringify({
parameter: searchMock.token2.address, items: [
redirect: true, searchMock.token1,
type: 'address', searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
],
}), }),
})); }));
...@@ -214,18 +262,12 @@ test('search with simple match', async({ mount, page }) => { ...@@ -214,18 +262,12 @@ test('search with simple match', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type(searchMock.token2.name); await page.getByPlaceholder(/search/i).type('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await page.waitForResponse(API_CHECK_REDIRECT_URL);
const resultText = page.getByText('Found 2 matching result');
await expect(resultText).toBeVisible();
const linkToToken = page.getByText(searchMock.token2.name); await page.getByRole('tab', { name: 'Addresses' }).click();
await expect(linkToToken).toHaveCount(1);
const linkToAddress = page.getByText(searchMock.token2.address); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
await expect(linkToAddress).toHaveCount(2);
}); });
test('recent keywords suggest +@mobile', async({ mount, page }) => { test('recent keywords suggest +@mobile', async({ mount, page }) => {
......
import { Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure } from '@chakra-ui/react'; import { Box, Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure, PopoverFooter } from '@chakra-ui/react';
import _debounce from 'lodash/debounce'; import _debounce from 'lodash/debounce';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import type { FormEvent, FocusEvent } from 'react'; import type { FormEvent, FocusEvent } from 'react';
import React from 'react'; import React from 'react';
import { Element } from 'react-scroll';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import { getRecentSearchKeywords, saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { getRecentSearchKeywords, saveToRecentKeywords } from 'lib/recentSearchKeywords';
import LinkInternal from 'ui/shared/LinkInternal';
import SearchBarInput from './SearchBarInput'; import SearchBarInput from './SearchBarInput';
import SearchBarRecentKeywords from './SearchBarRecentKeywords'; import SearchBarRecentKeywords from './SearchBarRecentKeywords';
import SearchBarSuggest from './SearchBarSuggest'; import SearchBarSuggest from './SearchBarSuggest/SearchBarSuggest';
import useSearchQuery from './useSearchQuery'; import useSearchQuery from './useSearchQuery';
type Props = { type Props = {
isHomepage?: boolean; isHomepage?: boolean;
} }
const SCROLL_CONTAINER_ID = 'search_bar_popover_content';
const SearchBar = ({ isHomepage }: Props) => { const SearchBar = ({ isHomepage }: Props) => {
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
const inputRef = React.useRef<HTMLFormElement>(null); const inputRef = React.useRef<HTMLFormElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null); const menuRef = React.useRef<HTMLDivElement>(null);
const scrollRef = React.useRef<HTMLDivElement>(null);
const menuWidth = React.useRef<number>(0); const menuWidth = React.useRef<number>(0);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const router = useRouter(); const router = useRouter();
const recentSearchKeywords = getRecentSearchKeywords(); const recentSearchKeywords = getRecentSearchKeywords();
const { searchTerm, handleSearchTermChange, query, pathname, redirectCheckQuery } = useSearchQuery(); const { searchTerm, handleSearchTermChange, query, pathname } = useSearchQuery();
const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => { const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
...@@ -119,15 +124,38 @@ const SearchBar = ({ isHomepage }: Props) => { ...@@ -119,15 +124,38 @@ const SearchBar = ({ isHomepage }: Props) => {
value={ searchTerm } value={ searchTerm }
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }> <PopoverContent
<PopoverBody py={ 6 } sx={ isHomepage ? { mark: { bgColor: 'green.100' } } : {} } color="chakra-body-text"> w={ `${ menuWidth.current }px` }
{ searchTerm.trim().length === 0 && recentSearchKeywords.length > 0 && ( ref={ menuRef }
<SearchBarRecentKeywords onClick={ handleSearchTermChange } onClear={ onClose }/> >
) } <PopoverBody p={ 0 } color="chakra-body-text">
{ searchTerm.trim().length > 0 && ( <Box
<SearchBarSuggest query={ query } redirectCheckQuery={ redirectCheckQuery } searchTerm={ searchTerm } onItemClick={ handleItemClick }/> maxH="50vh"
) } overflowY="scroll"
id={ SCROLL_CONTAINER_ID }
ref={ scrollRef }
as={ Element }
sx={ isHomepage ? { mark: { bgColor: 'green.100' } } : {} }
px={ 4 }
>
{ searchTerm.trim().length === 0 && recentSearchKeywords.length > 0 && (
<SearchBarRecentKeywords onClick={ handleSearchTermChange } onClear={ onClose }/>
) }
{ searchTerm.trim().length > 0 && (
<SearchBarSuggest query={ query } searchTerm={ searchTerm } onItemClick={ handleItemClick } containerId={ SCROLL_CONTAINER_ID }/>
) }
</Box>
</PopoverBody> </PopoverBody>
{ searchTerm.trim().length > 0 && query.data?.next_page_params && (
<PopoverFooter>
<LinkInternal
href={ route({ pathname: '/search-results', query: { q: searchTerm } }) }
fontSize="sm"
>
View all results
</LinkInternal>
</PopoverFooter>
) }
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
......
...@@ -42,7 +42,7 @@ const SearchBarSuggest = ({ onClick, onClear }: Props) => { ...@@ -42,7 +42,7 @@ const SearchBarSuggest = ({ onClick, onClear }: Props) => {
} }
return ( return (
<> <Box py={ 6 }>
{ !isMobile && ( { !isMobile && (
<Box pb={ 4 } mb={ 5 } borderColor="divider" borderBottomWidth="1px" _empty={{ display: 'none' }}> <Box pb={ 4 } mb={ 5 } borderColor="divider" borderBottomWidth="1px" _empty={{ display: 'none' }}>
<TextAd/> <TextAd/>
...@@ -82,7 +82,7 @@ const SearchBarSuggest = ({ onClick, onClear }: Props) => { ...@@ -82,7 +82,7 @@ const SearchBarSuggest = ({ onClick, onClear }: Props) => {
</Flex> </Flex>
</Box> </Box>
)) } )) }
</> </Box>
); );
}; };
......
import { Box, Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import _uniqBy from 'lodash/uniqBy';
import React from 'react';
import type { SearchRedirectResult, SearchResultItem } from 'types/api/search';
import useIsMobile from 'lib/hooks/useIsMobile';
import TextAd from 'ui/shared/ad/TextAd';
import ContentLoader from 'ui/shared/ContentLoader';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import SearchBarSuggestItem from './SearchBarSuggestItem';
const getUniqueIdentifier = (item: SearchResultItem) => {
switch (item.type) {
case 'contract':
case 'address': {
return item.type + item.address;
}
case 'transaction': {
return item.type + item.tx_hash;
}
case 'block': {
return item.type + (item.block_hash || item.block_number);
}
case 'token': {
return item.type + item.address;
}
}
};
interface Props {
query: QueryWithPagesResult<'search'>;
redirectCheckQuery: UseQueryResult<SearchRedirectResult>;
searchTerm: string;
onItemClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
const SearchBarSuggest = ({ query, redirectCheckQuery, searchTerm, onItemClick }: Props) => {
const isMobile = useIsMobile();
const simpleMatch: SearchResultItem | undefined = React.useMemo(() => {
if (!redirectCheckQuery.data || !redirectCheckQuery.data.redirect || !redirectCheckQuery.data.parameter) {
return;
}
switch (redirectCheckQuery.data?.type) {
case 'address': {
return {
type: 'address',
name: '',
address: redirectCheckQuery.data.parameter,
};
}
case 'transaction': {
return {
type: 'transaction',
tx_hash: redirectCheckQuery.data.parameter,
};
}
}
}, [ redirectCheckQuery.data ]);
const items = React.useMemo(() => {
return _uniqBy(
[
simpleMatch,
...(query.data?.items || []),
].filter(Boolean),
getUniqueIdentifier,
);
}, [ query.data?.items, simpleMatch ]);
const content = (() => {
if (query.isLoading && !simpleMatch) {
return <ContentLoader text="We are searching, please wait... " fontSize="sm"/>;
}
if (query.isError && !simpleMatch) {
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
}
const num = query.data?.next_page_params ? '50+' : items.length;
const resultText = items.length > 1 || query.pagination.page > 1 ? 'results' : 'result';
return (
<>
<Text fontWeight={ 500 } fontSize="sm">Found <Text fontWeight={ 700 } as="span">{ num }</Text> matching { resultText }</Text>
{ items.map((item, index) =>
<SearchBarSuggestItem key={ index } data={ item } isMobile={ isMobile } searchTerm={ searchTerm } onClick={ onItemClick }/>) }
{ query.isLoading && <ContentLoader text="We are still searching, please wait... " fontSize="sm" mt={ 5 }/> }
</>
);
})();
return (
<>
{ !isMobile && (
<Box pb={ 4 } mb={ 5 } borderColor="divider" borderBottomWidth="1px" _empty={{ display: 'none' }}>
<TextAd/>
</Box>
) }
{ content }
</>
);
};
export default SearchBarSuggest;
import { Box, Tab, TabList, Tabs, Text, useColorModeValue } from '@chakra-ui/react';
import throttle from 'lodash/throttle';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import type { SearchResultItem } from 'types/api/search';
import useIsMobile from 'lib/hooks/useIsMobile';
import TextAd from 'ui/shared/ad/TextAd';
import ContentLoader from 'ui/shared/ContentLoader';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import type { Category } from 'ui/shared/search/utils';
import { getItemCategory, searchCategories } from 'ui/shared/search/utils';
import SearchBarSuggestItem from './SearchBarSuggestItem';
interface Props {
query: QueryWithPagesResult<'search'>;
searchTerm: string;
onItemClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
containerId: string;
}
const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props) => {
const isMobile = useIsMobile();
const categoriesRefs = React.useRef<Array<HTMLParagraphElement>>([]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const [ tabIndex, setTabIndex ] = React.useState(0);
const handleScroll = React.useCallback(() => {
const container = document.getElementById(containerId);
if (!container || !query.data?.items.length) {
return;
}
const topLimit = container.getBoundingClientRect().y + (tabsRef.current?.clientHeight || 0) + 24;
if (categoriesRefs.current[categoriesRefs.current.length - 1].getBoundingClientRect().y <= topLimit) {
setTabIndex(categoriesRefs.current.length - 1);
return;
}
for (let i = 0; i < categoriesRefs.current.length - 1; i++) {
if (categoriesRefs.current[i].getBoundingClientRect().y <= topLimit && categoriesRefs.current[i + 1].getBoundingClientRect().y > topLimit) {
setTabIndex(i);
break;
}
}
}, [ containerId, query.data?.items ]);
React.useEffect(() => {
const container = document.getElementById(containerId);
const throttledHandleScroll = throttle(handleScroll, 300);
if (container) {
container.addEventListener('scroll', throttledHandleScroll);
}
return () => {
if (container) {
container.removeEventListener('scroll', throttledHandleScroll);
}
};
}, [ containerId, handleScroll ]);
const itemsGroups = React.useMemo(() => {
if (!query.data?.items) {
return {};
}
const map: Partial<Record<Category, Array<SearchResultItem>>> = {};
query.data?.items.forEach(item => {
const cat = getItemCategory(item);
if (cat) {
if (cat in map) {
map[cat]?.push(item);
} else {
map[cat] = [ item ];
}
}
});
return map;
}, [ query.data?.items ]);
const scrollToCategory = React.useCallback((index: number) => () => {
setTabIndex(index);
scroller.scrollTo(`cat_${ index }`, {
duration: 250,
smooth: true,
offset: -(tabsRef.current?.clientHeight || 0),
containerId: containerId,
});
}, [ containerId ]);
const bgColor = useColorModeValue('white', 'gray.900');
const content = (() => {
if (query.isLoading) {
return <ContentLoader text="We are searching, please wait... " fontSize="sm"/>;
}
if (query.isError) {
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
}
if (!query.data.items || query.data.items.length === 0) {
return <Text>No results found.</Text>;
}
const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
return (
<>
{ resultCategories.length > 1 && (
<Box position="sticky" top="0" width="100%" background={ bgColor } py={ 5 } my={ -5 } ref={ tabsRef }>
<Tabs variant="outline" colorScheme="gray" size="sm" index={ tabIndex }>
<TabList columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
{ resultCategories.map((cat, index) => <Tab key={ cat.id } onClick={ scrollToCategory(index) }>{ cat.title }</Tab>) }
</TabList>
</Tabs>
</Box>
) }
{ resultCategories.map((cat, indx) => {
return (
<Element name={ `cat_${ indx }` } key={ cat.id }>
<Text
fontSize="sm"
fontWeight={ 600 }
variant="secondary"
mt={ 6 }
mb={ 3 }
ref={ (el: HTMLParagraphElement) => categoriesRefs.current[indx] = el }
>
{ cat.title }
</Text>
{ itemsGroups[cat.id]?.map((item, index) =>
<SearchBarSuggestItem key={ index } data={ item } isMobile={ isMobile } searchTerm={ searchTerm } onClick={ onItemClick }/>,
) }
</Element>
);
}) }
</>
);
})();
return (
<Box mt={ 5 } mb={ 5 }>
{ !isMobile && (
<Box pb={ 4 } mb={ 5 } borderColor="divider" borderBottomWidth="1px" _empty={{ display: 'none' }}>
<TextAd/>
</Box>
) }
{ content }
</Box>
);
};
export default SearchBarSuggest;
import { Box, Text, Grid, Flex, Icon } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultAddressOrContract } from 'types/api/search';
import iconSuccess from 'icons/status/success.svg';
import highlightText from 'lib/highlightText';
import AddressIcon from 'ui/shared/address/AddressIcon';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultAddressOrContract;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestAddress = ({ data, isMobile, searchTerm }: Props) => {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
const icon = <AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} flexShrink={ 0 }/>;
const name = data.name && (
<Text
variant="secondary"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.name, searchTerm) }}/>
</Text>
);
const isContractVerified = data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/>;
const address = <HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>;
if (isMobile) {
return (
<>
<Grid templateColumns="24px 1fr" gap={ 2 }>
{ icon }
<Flex alignItems="center" overflow="hidden">
<Box
as={ shouldHighlightHash ? 'mark' : 'span' }
display="block"
overflow="hidden"
whiteSpace="nowrap"
fontWeight={ 700 }
>
{ address }
</Box>
{ isContractVerified }
</Flex>
</Grid>
{ name }
</>
);
}
return (
<Flex alignItems="center" gap={ 2 }>
{ icon }
<Flex alignItems="center" w="450px" overflow="hidden">
<Box
as={ shouldHighlightHash ? 'mark' : 'span' }
display="block"
overflow="hidden"
whiteSpace="nowrap"
fontWeight={ 700 }
>
{ address }
</Box>
{ isContractVerified }
</Flex>
{ name }
</Flex>
);
};
export default React.memo(SearchBarSuggestAddress);
import { Text, Icon, Flex, Grid } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultBlock } from 'types/api/search';
import blockIcon from 'icons/block.svg';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultBlock;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => {
const icon = <Icon as={ blockIcon } boxSize={ 6 } color="gray.500"/>;
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
const blockNumber = (
<Text
fontWeight={ 700 }
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.block_number.toString(), searchTerm) }}/>
</Text>
);
const hash = (
<Text
variant="secondary"
overflow="hidden"
whiteSpace="nowrap"
as={ shouldHighlightHash ? 'mark' : 'span' }
display="block"
>
<HashStringShortenDynamic hash={ data.block_hash } isTooltipDisabled/>
</Text>
);
const date = dayjs(data.timestamp).format('llll');
if (isMobile) {
return (
<>
<Flex alignItems="center" gap={ 2 }>
{ icon }
{ blockNumber }
</Flex>
{ hash }
<Text variant="secondary">{ date }</Text>
</>
);
}
return (
<Grid templateColumns="24px 200px minmax(auto, max-content) auto" gap={ 2 }>
{ icon }
{ blockNumber }
{ hash }
<Text variant="secondary" textAlign="end">{ date }</Text>
</Grid>
);
};
export default React.memo(SearchBarSuggestBlock);
import { chakra, useColorModeValue } from '@chakra-ui/react';
import type { LinkProps as NextLinkProps } from 'next/link';
import NextLink from 'next/link';
import { route } from 'nextjs-routes';
import React from 'react';
import type { SearchResultItem } from 'types/api/search';
import SearchBarSuggestAddress from './SearchBarSuggestAddress';
import SearchBarSuggestBlock from './SearchBarSuggestBlock';
import SearchBarSuggestLabel from './SearchBarSuggestLabel';
import SearchBarSuggestToken from './SearchBarSuggestToken';
import SearchBarSuggestTx from './SearchBarSuggestTx';
interface Props {
data: SearchResultItem;
isMobile: boolean | undefined;
searchTerm: string;
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => {
const url = (() => {
switch (data.type) {
case 'token': {
return route({ pathname: '/token/[hash]', query: { hash: data.address } });
}
case 'contract':
case 'address':
case 'label': {
return route({ pathname: '/address/[hash]', query: { hash: data.address } });
}
case 'transaction': {
return route({ pathname: '/tx/[hash]', query: { hash: data.tx_hash } });
}
case 'block': {
return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } });
}
}
})();
const content = (() => {
switch (data.type) {
case 'token': {
return <SearchBarSuggestToken data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
case 'contract':
case 'address': {
return <SearchBarSuggestAddress data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
case 'label': {
return <SearchBarSuggestLabel data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
case 'block': {
return <SearchBarSuggestBlock data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
case 'transaction': {
return <SearchBarSuggestTx data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
}
})();
return (
<NextLink href={ url as NextLinkProps['href'] } passHref legacyBehavior>
<chakra.a
py={ 3 }
px={ 1 }
display="flex"
flexDir="column"
rowGap={ 2 }
borderColor="divider"
borderBottomWidth="1px"
_last={{
borderBottomWidth: '0',
}}
_hover={{
bgColor: useColorModeValue('blue.50', 'gray.800'),
}}
fontSize="sm"
_first={{
mt: 2,
}}
onClick={ onClick }
>
{ content }
</chakra.a>
</NextLink>
);
};
export default React.memo(SearchBarSuggestItem);
import { Grid, Text, Flex, Icon } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultLabel } from 'types/api/search';
import labelIcon from 'icons/publictags.svg';
import iconSuccess from 'icons/status/success.svg';
import highlightText from 'lib/highlightText';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultLabel;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestLabel = ({ data, isMobile, searchTerm }: Props) => {
const icon = <Icon as={ labelIcon } boxSize={ 6 } color="gray.500"/>;
const name = (
<Text
fontWeight={ 700 }
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.name, searchTerm) }}/>
</Text>
);
const address = (
<Text
overflow="hidden"
whiteSpace="nowrap"
variant="secondary"
>
<HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>
</Text>
);
const isContractVerified = data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500"/>;
if (isMobile) {
return (
<>
<Flex alignItems="center" overflow="hidden" gap={ 2 }>
{ icon }
{ name }
</Flex>
<Flex alignItems="center" overflow="hidden" gap={ 1 }>
{ address }
{ isContractVerified }
</Flex>
</>
);
}
return (
<Grid alignItems="center" gridTemplateColumns="24px 200px max-content 24px" gap={ 2 }>
{ icon }
{ name }
<Flex alignItems="center" overflow="hidden" gap={ 1 }>
{ address }
{ isContractVerified }
</Flex>
</Grid>
);
};
export default React.memo(SearchBarSuggestLabel);
import { Grid, Text, Flex, Icon } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultToken } from 'types/api/search';
import iconSuccess from 'icons/status/success.svg';
import highlightText from 'lib/highlightText';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
data: SearchResultToken;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestToken = ({ data, isMobile, searchTerm }: Props) => {
const icon = <TokenLogo boxSize={ 6 } data={ data }/>;
const name = (
<Text
fontWeight={ 700 }
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.name + (data.symbol ? ` (${ data.symbol })` : ''), searchTerm) }}/>
</Text>
);
const address = (
<Text variant="secondary" whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>
</Text>
);
const contractVerifiedIcon = data.is_smart_contract_verified && <Icon as={ iconSuccess } color="green.500" ml={ 1 }/>;
const additionalInfo = (
<Text overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
{ data.token_type === 'ERC-20' && data.exchange_rate && `$${ Number(data.exchange_rate).toLocaleString() }` }
{ data.token_type !== 'ERC-20' && data.total_supply && `Items ${ Number(data.total_supply).toLocaleString() }` }
</Text>
);
if (isMobile) {
const templateCols = `1fr
${ (data.token_type === 'ERC-20' && data.exchange_rate) || (data.token_type !== 'ERC-20' && data.total_supply) ? ' auto' : '' }`;
return (
<>
<Flex alignItems="center" gap={ 2 }>
{ icon }
{ name }
</Flex>
<Grid templateColumns={ templateCols } alignItems="center" gap={ 2 }>
<Flex alignItems="center" overflow="hidden">
{ address }
{ contractVerifiedIcon }
</Flex>
{ additionalInfo }
</Grid>
</>
);
}
return (
<Grid templateColumns="24px 200px 1fr auto" gap={ 2 }>
{ icon }
{ name }
<Flex alignItems="center" overflow="hidden">
{ address }
{ contractVerifiedIcon }
</Flex>
{ additionalInfo }
</Grid>
);
};
export default React.memo(SearchBarSuggestToken);
import { chakra, Grid, Text, Flex, Icon } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultTx } from 'types/api/search';
import txIcon from 'icons/transactions.svg';
import dayjs from 'lib/date/dayjs';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultTx;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestTx = ({ data, isMobile }: Props) => {
const icon = <Icon as={ txIcon } boxSize={ 6 } color="gray.500"/>;
const hash = (
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
<HashStringShortenDynamic hash={ data.tx_hash } isTooltipDisabled/>
</chakra.mark>
);
const date = dayjs(data.timestamp).format('llll');
if (isMobile) {
return (
<>
<Flex alignItems="center" justifyContent="space-between" gap={ 2 }>
{ icon }
{ hash }
</Flex>
<Text variant="secondary">{ date }</Text>
</>
);
}
return (
<Grid templateColumns="24px minmax(auto, max-content) auto" gap={ 2 }>
{ icon }
{ hash }
<Text variant="secondary" textAlign="end">{ date }</Text>
</Grid>
);
};
export default React.memo(SearchBarSuggestTx);
import { chakra, Text, Flex, useColorModeValue, Icon, Box } from '@chakra-ui/react';
import type { LinkProps as NextLinkProps } from 'next/link';
import NextLink from 'next/link';
import { route } from 'nextjs-routes';
import React from 'react';
import type { SearchResultItem } from 'types/api/search';
import blockIcon from 'icons/block.svg';
import labelIcon from 'icons/publictags.svg';
import txIcon from 'icons/transactions.svg';
import highlightText from 'lib/highlightText';
import AddressIcon from 'ui/shared/address/AddressIcon';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
data: SearchResultItem;
isMobile: boolean | undefined;
searchTerm: string;
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => {
const url = (() => {
switch (data.type) {
case 'token': {
return route({ pathname: '/token/[hash]', query: { hash: data.address } });
}
case 'contract':
case 'address':
case 'label': {
return route({ pathname: '/address/[hash]', query: { hash: data.address } });
}
case 'transaction': {
return route({ pathname: '/tx/[hash]', query: { hash: data.tx_hash } });
}
case 'block': {
return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } });
}
}
})();
const firstRow = (() => {
switch (data.type) {
case 'token': {
const name = data.name + (data.symbol ? ` (${ data.symbol })` : '');
return (
<>
<TokenLogo boxSize={ 6 } data={ data } flexShrink={ 0 }/>
<Text fontWeight={ 700 } ml={ 2 } w="200px" overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis" flexShrink={ 0 }>
<span dangerouslySetInnerHTML={{ __html: highlightText(name, searchTerm) }}/>
</Text>
{ !isMobile && (
<Text overflow="hidden" whiteSpace="nowrap" ml={ 2 } variant="secondary">
<HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>
</Text>
) }
</>
);
}
case 'contract':
case 'address': {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
return (
<>
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
<HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>
</Box>
{ !isMobile && data.name && (
<Text variant="secondary" ml={ 2 }>
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? data.name : highlightText(data.name, searchTerm) }}/>
</Text>
) }
</>
);
}
case 'label': {
return (
<>
<Icon as={ labelIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<Text fontWeight={ 700 } ml={ 2 } w="200px" overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis" flexShrink={ 0 }>
<span dangerouslySetInnerHTML={{ __html: highlightText(data.name, searchTerm) }}/>
</Text>
{ !isMobile && (
<Text overflow="hidden" whiteSpace="nowrap" ml={ 2 } variant="secondary">
<HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>
</Text>
) }
</>
);
}
case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return (
<>
<Icon as={ blockIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<Box fontWeight={ 700 } as={ shouldHighlightHash ? 'span' : 'mark' }>{ data.block_number }</Box>
{ !isMobile && (
<Text variant="secondary" overflow="hidden" whiteSpace="nowrap" ml={ 2 } as={ shouldHighlightHash ? 'mark' : 'span' } display="block">
<HashStringShortenDynamic hash={ data.block_hash } isTooltipDisabled/>
</Text>
) }
</>
);
}
case 'transaction': {
return (
< >
<Icon as={ txIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
<HashStringShortenDynamic hash={ data.tx_hash } isTooltipDisabled/>
</chakra.mark>
</>
);
}
}
})();
const secondRow = (() => {
if (!isMobile) {
return null;
}
switch (data.type) {
case 'token':
case 'label': {
return (
<Text variant="secondary" whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>
</Text>
);
}
case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return (
<Text variant="secondary" whiteSpace="nowrap" overflow="hidden" as={ shouldHighlightHash ? 'mark' : 'span' } display="block">
<HashStringShortenDynamic hash={ data.block_hash } isTooltipDisabled/>
</Text>
);
}
case 'contract':
case 'address': {
if (!data.name) {
return null;
}
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
return (
<Text variant="secondary" whiteSpace="nowrap" overflow="hidden">
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? data.name : highlightText(data.name, searchTerm) }}/>
</Text>
);
}
default: {
return null;
}
}
})();
return (
<NextLink href={ url as NextLinkProps['href'] } passHref legacyBehavior>
<chakra.a
py={ 3 }
px={ 1 }
display="flex"
flexDir="column"
rowGap={ 2 }
borderColor="divider"
borderBottomWidth="1px"
_last={{
borderBottomWidth: '0',
}}
_hover={{
bgColor: useColorModeValue('blue.50', 'gray.800'),
}}
fontSize="sm"
_first={{
mt: 2,
}}
onClick={ onClick }
>
<Flex display="flex" alignItems="center">
{ firstRow }
</Flex>
{ secondRow }
</chakra.a>
</NextLink>
);
};
export default React.memo(SearchBarSuggestItem);
...@@ -195,7 +195,7 @@ const TxDetails = () => { ...@@ -195,7 +195,7 @@ const TxDetails = () => {
<Icon as={ clockIcon } boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/> <Icon as={ clockIcon } boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>{ dayjs(data.timestamp).fromNow() }</Skeleton> <Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>{ dayjs(data.timestamp).fromNow() }</Skeleton>
<TextSeparator/> <TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">{ dayjs(data.timestamp).format('LLLL') }</Skeleton> <Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">{ dayjs(data.timestamp).format('llll') }</Skeleton>
<TextSeparator color="gray.500"/> <TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isPlaceholderData } color="text_secondary"> <Skeleton isLoaded={ !isPlaceholderData } color="text_secondary">
<span>{ getConfirmationDuration(data.confirmation_duration) }</span> <span>{ getConfirmationDuration(data.confirmation_duration) }</span>
......
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