Commit 20bf686c authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #473 from blockscout/search-bar

search bar and search results page
parents 9f7e0784 956a59c3
......@@ -471,6 +471,7 @@ frontend:
- "/block"
- "/address"
- "/stats"
- "/search-results"
resources:
limits:
memory:
......
......@@ -320,7 +320,9 @@ frontend:
- "/login"
- "/address"
- "/stats"
- "/search-results"
- "/token"
resources:
limits:
memory:
......
<svg viewBox="0 0 201 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M100.027 8.408c-2.187 0-4.238.41-6.152 1.23a15.973 15.973 0 0 0-4.922 3.35c-1.413 1.413-2.53 3.076-3.35 4.99-.82 1.869-1.23 3.897-1.23 6.084a3.372 3.372 0 0 1-3.913 3.328l-2.409-.392a2.974 2.974 0 0 1-2.496-2.936c0-3.326.615-6.448 1.845-9.365 1.276-2.916 3.008-5.468 5.196-7.656 2.187-2.187 4.74-3.896 7.656-5.127C93.169.638 96.29 0 99.617 0h4.17c3.327 0 6.449.592 9.365 1.777 2.963 1.185 5.537 2.894 7.725 5.127 2.187 2.233 3.896 4.922 5.127 8.067 1.276 3.099 1.914 6.608 1.914 10.527 0 3.874-.592 7.77-1.777 11.69a67.002 67.002 0 0 1-4.58 11.552 87.122 87.122 0 0 1-6.495 11.006 147.24 147.24 0 0 1-7.451 10.049c-5.97 7.337-12.737 14.401-20.302 21.191h32.88a8.409 8.409 0 0 1 8.409 8.408H73.436v-2.253c0-2.988 1.345-5.81 3.612-7.756 7.075-6.073 13.482-12.33 19.22-18.77a162.094 162.094 0 0 0 8.408-10.322 100.413 100.413 0 0 0 7.314-11.211c2.142-3.874 3.851-7.793 5.127-11.758 1.322-4.01 1.983-7.952 1.983-11.826 0-2.871-.433-5.378-1.299-7.52-.866-2.141-2.028-3.919-3.487-5.332a13.909 13.909 0 0 0-5.058-3.144 16.03 16.03 0 0 0-5.879-1.094h-3.35ZM48.193 82.441v16.953a8.818 8.818 0 0 1-8.818-8.818v-8.135H3.3a3.3 3.3 0 0 1-2.872-4.926L42.701 2.857a2.937 2.937 0 0 1 5.492 1.447v69.73h8.409a8.408 8.408 0 0 1-8.409 8.407Zm-8.818-56.943L12.373 74.033h27.002V25.498Zm126.5-15.86c1.914-.82 3.965-1.23 6.152-1.23h3.35c2.051 0 4.01.365 5.879 1.094 1.914.683 3.6 1.732 5.058 3.144 1.459 1.413 2.621 3.19 3.487 5.332.866 2.142 1.299 4.649 1.299 7.52 0 3.874-.661 7.816-1.983 11.826-1.276 3.965-2.985 7.884-5.127 11.758a100.413 100.413 0 0 1-7.314 11.211 162.124 162.124 0 0 1-8.408 10.322c-5.738 6.44-12.145 12.697-19.22 18.77-2.267 1.946-3.612 4.768-3.612 7.756v2.253h55.166a8.409 8.409 0 0 0-8.409-8.408h-32.881c7.566-6.79 14.333-13.854 20.303-21.191a147.24 147.24 0 0 0 7.451-10.049 87.122 87.122 0 0 0 6.495-11.006 67.002 67.002 0 0 0 4.58-11.553c1.185-3.919 1.777-7.815 1.777-11.689 0-3.92-.638-7.428-1.914-10.527-1.231-3.145-2.94-5.834-5.127-8.067-2.188-2.233-4.762-3.942-7.725-5.127C182.236.592 179.114 0 175.787 0h-4.17c-3.327 0-6.448.638-9.365 1.914-2.917 1.23-5.469 2.94-7.656 5.127-2.188 2.188-3.92 4.74-5.196 7.656-1.23 2.917-1.845 6.039-1.845 9.366a2.974 2.974 0 0 0 2.496 2.935l2.409.392a3.371 3.371 0 0 0 3.913-3.328c0-2.187.41-4.215 1.231-6.084.82-1.914 1.936-3.577 3.349-4.99a15.982 15.982 0 0 1 4.922-3.35Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M100.027 8.408c-2.187 0-4.238.41-6.152 1.23a15.973 15.973 0 0 0-4.922 3.35c-1.413 1.413-2.53 3.076-3.35 4.99-.82 1.869-1.23 3.897-1.23 6.084a3.372 3.372 0 0 1-3.913 3.328l-2.409-.392a2.974 2.974 0 0 1-2.496-2.936c0-3.326.615-6.448 1.845-9.365 1.276-2.916 3.008-5.468 5.196-7.656 2.187-2.187 4.74-3.896 7.656-5.127C93.169.638 96.29 0 99.617 0h4.17c3.327 0 6.449.592 9.365 1.777 2.963 1.185 5.537 2.894 7.725 5.127 2.187 2.233 3.896 4.922 5.127 8.067 1.276 3.099 1.914 6.608 1.914 10.527 0 3.874-.592 7.77-1.777 11.69a67.002 67.002 0 0 1-4.58 11.552 87.122 87.122 0 0 1-6.495 11.006 147.24 147.24 0 0 1-7.451 10.049c-5.97 7.337-12.737 14.401-20.302 21.191h32.88a8.409 8.409 0 0 1 8.409 8.408H73.436v-2.253c0-2.988 1.345-5.81 3.612-7.756 7.075-6.073 13.482-12.33 19.22-18.77a162.094 162.094 0 0 0 8.408-10.322 100.413 100.413 0 0 0 7.314-11.211c2.142-3.874 3.851-7.793 5.127-11.758 1.322-4.01 1.983-7.952 1.983-11.826 0-2.871-.433-5.378-1.299-7.52-.866-2.141-2.028-3.919-3.487-5.332a13.909 13.909 0 0 0-5.058-3.144 16.03 16.03 0 0 0-5.879-1.094h-3.35ZM48.193 82.441v16.953a8.818 8.818 0 0 1-8.818-8.818v-8.135H3.3a3.3 3.3 0 0 1-2.872-4.926L42.701 2.857a2.937 2.937 0 0 1 5.492 1.447v69.73h8.409a8.408 8.408 0 0 1-8.409 8.407Zm-8.818-56.943L12.373 74.033h27.002V25.498Zm126.5-15.86c1.914-.82 3.965-1.23 6.152-1.23h3.35c2.051 0 4.01.365 5.879 1.094a13.91 13.91 0 0 1 5.058 3.144c1.459 1.413 2.621 3.19 3.487 5.332.866 2.142 1.299 4.649 1.299 7.52 0 3.874-.661 7.816-1.983 11.826-1.276 3.965-2.985 7.884-5.127 11.758a100.413 100.413 0 0 1-7.314 11.211 162.124 162.124 0 0 1-8.408 10.322c-5.738 6.44-12.145 12.697-19.22 18.77-2.267 1.946-3.612 4.768-3.612 7.756v2.253h55.166a8.409 8.409 0 0 0-8.409-8.408h-32.881c7.566-6.79 14.333-13.854 20.303-21.191a147.24 147.24 0 0 0 7.451-10.049 87.122 87.122 0 0 0 6.495-11.006 67.002 67.002 0 0 0 4.58-11.553c1.185-3.919 1.777-7.815 1.777-11.689 0-3.92-.638-7.428-1.914-10.527-1.231-3.145-2.94-5.834-5.127-8.067-2.188-2.233-4.762-3.942-7.725-5.127C182.236.592 179.114 0 175.787 0h-4.17c-3.327 0-6.448.638-9.365 1.914-2.917 1.23-5.469 2.94-7.656 5.127-2.188 2.188-3.92 4.74-5.196 7.656-1.23 2.917-1.845 6.039-1.845 9.366a2.974 2.974 0 0 0 2.496 2.935l2.409.392a3.371 3.371 0 0 0 3.913-3.328c0-2.187.41-4.215 1.231-6.084.82-1.914 1.936-3.577 3.349-4.99a15.982 15.982 0 0 1 4.922-3.35Z" fill="currentColor"/>
</svg>
......@@ -19,6 +19,7 @@ import type { IndexingStatus } from 'types/api/indexingStatus';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { RawTracesResponse } from 'types/api/rawTrace';
import type { SearchResult, SearchResultFilters } from 'types/api/search';
import type { Stats, Charts, HomeStats } from 'types/api/stats';
import type { TokenCounters, TokenInfo, TokenHolders } from 'types/api/tokenInfo';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
......@@ -201,6 +202,23 @@ export const RESOURCES = {
path: '/api/v2/main-page/indexing-status',
},
// SEARCH
search: {
path: '/api/v2/search',
paginationFields: [
'address_hash' as const,
'block_hash' as const,
'holder_count' as const,
'inserted_at' as const,
'item_type' as const,
'items_count' as const,
'name' as const,
'q' as const,
'tx_hash' as const,
],
filterFields: [ 'q' ],
},
// DEPRECATED
old_api: {
path: '/api',
......@@ -232,6 +250,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'address_logs' |
'search' |
'token_holders';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
......@@ -276,6 +295,7 @@ Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'token' ? TokenInfo :
Q extends 'token_counters' ? TokenCounters :
Q extends 'token_holders' ? TokenHolders :
Q extends 'search' ? SearchResult :
Q extends 'contract' ? SmartContract :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -287,5 +307,6 @@ Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters :
Q extends 'tx_token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
Q extends 'address_token_transfers' ? AddressTokenTransferFilters :
Q extends 'search' ? SearchResultFilters :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -6,18 +6,18 @@ import useFetch from 'lib/hooks/useFetch';
import buildUrl from './buildUrl';
import { RESOURCES } from './resources';
import type { ResourceError, ApiResource } from './resources';
import type { ApiResource } from './resources';
export interface Params {
pathParams?: Record<string, string | undefined>;
queryParams?: Record<string, string | number | undefined>;
fetchParams?: Pick<FetchParams, 'body' | 'method'>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal'>;
}
export default function useApiFetch() {
const fetch = useFetch();
return React.useCallback(<R extends keyof typeof RESOURCES, SuccessType = unknown, ErrorType = ResourceError>(
return React.useCallback(<R extends keyof typeof RESOURCES, SuccessType = unknown, ErrorType = unknown>(
resourceName: R,
{ pathParams, queryParams, fetchParams }: Params = {},
) => {
......
export default function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
import escapeRegExp from 'lib/escapeRegExp';
export default function highlightText(text: string, query: string) {
const regex = new RegExp('(' + escapeRegExp(query) + ')', 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
import React from 'react';
export default function useDebounce(value: string, delay: number) {
const [ debouncedValue, setDebouncedValue ] = React.useState(value);
React.useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[ value, delay ],
);
return debouncedValue;
}
......@@ -10,6 +10,7 @@ import { getResourceKey } from 'lib/api/useApiQuery';
export interface Params {
method?: RequestInit['method'];
headers?: RequestInit['headers'];
signal?: RequestInit['signal'];
body?: Record<string, unknown>;
credentials?: RequestCredentials;
}
......
import { useQueryClient } from '@tanstack/react-query';
import difference from 'lodash/difference';
import mapValues from 'lodash/mapValues';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
......@@ -43,7 +44,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
const isMounted = React.useRef(false);
const canGoBackwards = React.useRef(!router.query.page);
const queryParams = { ...filters, ...pageParams[page] };
const queryParams = { ...pageParams[page], ...filters };
const scrollToTop = useCallback(() => {
scrollRef?.current ? scrollRef.current.scrollIntoView(true) : animateScroll.scrollToTop({ duration: 0 });
......@@ -73,7 +74,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
setPage(prev => prev + 1);
const nextPageQuery = { ...router.query };
Object.entries(nextPageParams).forEach(([ key, val ]) => nextPageQuery[key] = val.toString());
Object.entries(nextPageParams).forEach(([ key, val ]) => nextPageQuery[key] = String(val));
nextPageQuery.page = String(page + 1);
setHasPagination(true);
......@@ -87,7 +88,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
// we dont have pagination params for the first page
let nextPageQuery: typeof router.query = { ...router.query };
if (page === 2) {
nextPageQuery = omit(router.query, resource.paginationFields, 'page');
nextPageQuery = omit(router.query, difference(resource.paginationFields, resource.filterFields), 'page');
canGoBackwards.current = true;
} else {
const nextPageParams = pageParams[page - 1];
......@@ -102,13 +103,14 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
page === 2 && queryClient.removeQueries({ queryKey: [ resourceName ] });
});
setHasPagination(true);
}, [ router, page, resource.paginationFields, pageParams, scrollToTop, queryClient, resourceName ]);
}, [ router, page, resource.paginationFields, resource.filterFields, pageParams, scrollToTop, queryClient, resourceName ]);
const resetPage = useCallback(() => {
queryClient.removeQueries({ queryKey: [ resourceName ] });
scrollToTop();
router.push({ pathname: router.pathname, query: omit(router.query, resource.paginationFields, 'page') }, undefined, { shallow: true }).then(() => {
const nextRouterQuery = omit(router.query, difference(resource.paginationFields, resource.filterFields), 'page');
router.push({ pathname: router.pathname, query: nextRouterQuery }, undefined, { shallow: true }).then(() => {
queryClient.removeQueries({ queryKey: [ resourceName ] });
setPage(1);
setPageParams({});
......@@ -121,10 +123,10 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
});
setHasPagination(true);
}, [ queryClient, resourceName, router, resource.paginationFields, scrollToTop ]);
}, [ queryClient, resourceName, router, resource.paginationFields, resource.filterFields, scrollToTop ]);
const onFilterChange = useCallback((newFilters: PaginationFilters<Resource> | undefined) => {
const newQuery = omit(router.query, resource.paginationFields, 'page', resource.filterFields);
const newQuery = omit<typeof router.query>(router.query, resource.paginationFields, 'page', resource.filterFields);
if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => {
if (value && value.length) {
......@@ -132,7 +134,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}
});
}
setHasPagination(false);
scrollToTop();
router.push(
{
......@@ -147,14 +149,12 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
});
}, [ router, resource.paginationFields, resource.filterFields, scrollToTop ]);
const hasPaginationParams = Object.keys(currPageParams || {}).length > 0;
const nextPageParams = data?.next_page_params;
const pagination = {
page,
onNextPageClick,
onPrevPageClick,
hasPaginationParams,
resetPage,
hasNextPage: nextPageParams ? Object.keys(nextPageParams).length > 0 : false,
canGoBackwards: canGoBackwards.current,
......
import React from 'react';
// run effect only if value is updated since initial mount
const useUpdateValueEffect = (effect: () => void, value: string) => {
const mountedRef = React.useRef(false);
const valueRef = React.useRef<string>();
const isChangedRef = React.useRef(false);
React.useEffect(() => {
mountedRef.current = true;
valueRef.current = value;
return () => {
mountedRef.current = false;
valueRef.current = undefined;
isChangedRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (mountedRef.current && (value !== valueRef.current || isChangedRef.current)) {
isChangedRef.current = true;
return effect();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ value ]);
};
export default useUpdateValueEffect;
import type { SearchResultToken, SearchResultBlock, SearchResultAddressOrContract, SearchResultTx, SearchResult } from 'types/api/search';
export const token1: SearchResultToken = {
address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
address_url: '/address/0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
name: 'Toms NFT',
symbol: 'TNT',
token_url: '/token/0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
type: 'token' as const,
};
export const token2: SearchResultToken = {
address: '0xC35Cc7223B0175245E9964f2E3119c261E8e21F9',
address_url: '/address/0xC35Cc7223B0175245E9964f2E3119c261E8e21F9',
name: 'TomToken',
symbol: 'pdE1B',
token_url: '/token/0xC35Cc7223B0175245E9964f2E3119c261E8e21F9',
type: 'token' as const,
};
export const block1: SearchResultBlock = {
block_hash: '0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd1',
block_number: 8198536,
type: 'block' as const,
url: '/block/0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd1',
};
export const address1: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: null,
type: 'address' as const,
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
};
export const contract1: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: 'Unknown contract in this network',
type: 'contract' as const,
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
};
export const tx1: SearchResultTx = {
tx_hash: '0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
type: 'transaction' as const,
url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
};
export const baseResponse: SearchResult = {
items: [
token1,
token2,
block1,
address1,
contract1,
tx1,
],
next_page_params: null,
};
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import SearchResults from 'ui/pages/SearchResults';
const SearchResultsPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head>
<title>{ title }</title>
</Head>
<SearchResults/>
</>
);
};
export default SearchResultsPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
......@@ -14,7 +14,7 @@ const $arrowBg = cssVar('popper-arrow-bg');
const $arrowShadowColor = cssVar('popper-arrow-shadow-color');
const baseStylePopper = defineStyle({
zIndex: 20,
zIndex: 'popover',
});
const baseStyleContent = defineStyle((props) => {
......
......@@ -9,6 +9,10 @@ const global = (props: StyleFunctionProps) => ({
...getDefaultTransitionProps(),
'-webkit-tap-highlight-color': 'transparent',
},
mark: {
bgColor: 'yellow.200',
color: 'inherit',
},
'svg *::selection': {
color: 'none',
background: 'none',
......
export type SearchResultType = 'token' | 'address' | 'block' | 'transaction' | 'contract';
export interface SearchResultToken {
type: 'token';
name: string;
symbol: string;
address: string;
token_url: string;
address_url: string;
}
export interface SearchResultAddressOrContract {
type: 'address' | 'contract';
name: string | null;
address: string;
url: string;
}
export interface SearchResultBlock {
type: 'block';
block_number: number;
block_hash: string;
url: string;
}
export interface SearchResultTx {
type: 'transaction';
tx_hash: string;
url: string;
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx;
export interface SearchResult {
items: Array<SearchResultItem>;
next_page_params: {
'address_hash': string | null;
'block_hash': string | null;
'holder_count': number | null;
'inserted_at': string | null;
'item_type': SearchResultType;
'items_count': number;
'name': string;
'q': string;
'tx_hash': string | null;
} | null;
}
export interface SearchResultFilters {
q: string;
}
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as searchMock from 'mocks/search/index';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import SearchResults from './SearchResults';
test('search by name +@mobile +@dark-mode', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: 'o' },
},
};
await page.route(buildApiUrl('search') + '?q=o', (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.token1,
searchMock.token2,
searchMock.contract1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by address hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.address1.address },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.address1.address }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.address1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by block number +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.block1.block_number },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.block1.block_number }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.block1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by block hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.block1.block_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.block1.block_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.block1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by tx hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.tx1.tx_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.tx1.tx_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.tx1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
import { Box, chakra, Table, Tbody, Tr, Th, Skeleton, Show, Hide } from '@chakra-ui/react';
import type { FormEvent } from 'react';
import React from 'react';
import SearchResultListItem from 'ui/searchResults/SearchResultListItem';
import SearchResultTableItem from 'ui/searchResults/SearchResultTableItem';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import { default as Thead } from 'ui/shared/TheadSticky';
import Header from 'ui/snippets/header/Header';
import SearchBarInput from 'ui/snippets/searchBar/SearchBarInput';
import useSearchQuery from 'ui/snippets/searchBar/useSearchQuery';
const SearchResultsPageContent = () => {
const { query, searchTerm, debouncedSearchTerm, handleSearchTermChange } = useSearchQuery(true);
const { data, isError, isLoading, pagination, isPaginationVisible } = query;
const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
}, [ ]);
const content = (() => {
if (isError) {
return <DataFetchAlert/>;
}
if (isLoading) {
return (
<Box>
<Show below="lg">
<SkeletonList/>
</Show>
<Hide below="lg">
<SkeletonTable columns={ [ '50%', '50%', '150px' ] }/>
</Hide>
</Box>
);
}
if (data.items.length === 0) {
return null;
}
return (
<>
<Show below="lg" ssr={ false }>
{ data.items.map((item, index) => <SearchResultListItem key={ index } data={ item } searchTerm={ debouncedSearchTerm }/>) }
</Show>
<Hide below="lg" ssr={ false }>
<Table variant="simple" size="md" fontWeight={ 500 }>
<Thead top={ isPaginationVisible ? 80 : 0 }>
<Tr>
<Th width="50%">Search Result</Th>
<Th width="50%"/>
<Th width="150px">Category</Th>
</Tr>
</Thead>
<Tbody>
{ data.items.map((item, index) => <SearchResultTableItem key={ index } data={ item } searchTerm={ debouncedSearchTerm }/>) }
</Tbody>
</Table>
</Hide>
</>
);
})();
const bar = (() => {
if (isError) {
return null;
}
const text = isLoading ? (
<Skeleton h={ 6 } w="280px" borderRadius="full" mb={ isPaginationVisible ? 0 : 6 }/>
) : (
(
<Box mb={ isPaginationVisible ? 0 : 6 } lineHeight="32px">
<span>Found </span>
<chakra.span fontWeight={ 700 }>
{ pagination.page > 1 ? 50 : data.items.length }{ data.next_page_params || pagination.page > 1 ? '+' : '' }
</chakra.span>
<span> matching result{ data.items.length > 1 || pagination.page > 1 ? 's' : '' } for </span>
<chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span>
</Box>
)
);
if (!isPaginationVisible) {
return text;
}
return (
<>
<Box display={{ base: 'block', lg: 'none' }}>{ text }</Box>
<ActionBar mt={{ base: 0, lg: -6 }} alignItems="center">
<Box display={{ base: 'none', lg: 'block' }}>{ text }</Box>
<Pagination { ...pagination }/>
</ActionBar>
</>
);
})();
const inputRef = React.useRef<HTMLFormElement>(null);
const handelHide = React.useCallback(() => {
inputRef.current?.querySelector('input')?.blur();
}, [ ]);
const handleClear = React.useCallback(() => {
handleSearchTermChange('');
inputRef.current?.querySelector('input')?.focus();
}, [ handleSearchTermChange ]);
const renderSearchBar = React.useCallback(() => {
return (
<SearchBarInput
ref={ inputRef }
onChange={ handleSearchTermChange }
onSubmit={ handleSubmit }
value={ searchTerm }
onHide={ handelHide }
onClear={ handleClear }
/>
);
}, [ handleSearchTermChange, handleSubmit, searchTerm, handelHide, handleClear ]);
const renderHeader = React.useCallback(() => {
return <Header renderSearchBar={ renderSearchBar }/>;
}, [ renderSearchBar ]);
return (
<Page renderHeader={ renderHeader }>
<PageTitle text="Search results"/>
{ bar }
{ content }
</Page>
);
};
export default SearchResultsPageContent;
......@@ -35,7 +35,7 @@ const Transactions = () => {
];
return (
<Page hideMobileHeaderOnScrollDown>
<Page>
<Box h="100%">
<PageTitle text="Transactions" withTextAd/>
<RoutedTabs
......
import { Text, Link, Flex, Icon, Box, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultItem } from 'types/api/search';
import blockIcon from 'icons/block.svg';
import txIcon from 'icons/transactions.svg';
import highlightText from 'lib/highlightText';
import link from 'lib/link/link';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import ListItemMobile from 'ui/shared/ListItemMobile';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
data: SearchResultItem;
searchTerm: string;
}
const SearchResultListItem = ({ data, searchTerm }: Props) => {
const firstRow = (() => {
switch (data.type) {
case 'token': {
const name = data.name + (data.symbol ? ` (${ data.symbol })` : '');
return (
<Flex alignItems="flex-start">
<TokenLogo boxSize={ 6 } hash={ data.address } name={ data.name } flexShrink={ 0 }/>
<Link ml={ 2 } href={ link('token_index', { hash: data.address }) } fontWeight={ 700 } wordBreak="break-all">
<chakra.span dangerouslySetInnerHTML={{ __html: highlightText(name, searchTerm) }}/>
</Link>
</Flex>
);
}
case 'contract':
case 'address': {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
return (
<Address>
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" whiteSpace="nowrap" overflow="hidden">
<AddressLink hash={ data.address } fontWeight={ 700 } display="block" w="100%"/>
</Box>
</Address>
);
}
case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return (
<Flex alignItems="center">
<Icon as={ blockIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<Link fontWeight={ 700 } href={ link('block', { id: String(data.block_number) }) }>
<Box as={ shouldHighlightHash ? 'span' : 'mark' }>{ data.block_number }</Box>
</Link>
</Flex>
);
}
case 'transaction': {
return (
<Flex alignItems="center" overflow="hidden">
<Icon as={ txIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<chakra.mark display="block" overflow="hidden">
<AddressLink hash={ data.tx_hash } type="transaction" fontWeight={ 700 } display="block"/>
</chakra.mark>
</Flex>
);
}
}
})();
const secondRow = (() => {
switch (data.type) {
case 'token': {
return (
<HashStringShortenDynamic hash={ data.address }/>
);
}
case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return (
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" w="100%" whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ data.block_hash }/>
</Box>
);
}
case 'contract':
case 'address': {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
return data.name ? <span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? data.name : highlightText(data.name, searchTerm) }}/> : null;
}
default:
return null;
}
})();
return (
<ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }>
<Flex justifyContent="space-between" w="100%" overflow="hidden" lineHeight={ 6 }>
{ firstRow }
<Text variant="secondary" ml={ 8 } textTransform="capitalize">{ data.type }</Text>
</Flex>
{ secondRow }
</ListItemMobile>
);
};
export default SearchResultListItem;
import { Tr, Td, Text, Link, Flex, Icon, Box } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultItem } from 'types/api/search';
import blockIcon from 'icons/block.svg';
import txIcon from 'icons/transactions.svg';
import highlightText from 'lib/highlightText';
import link from 'lib/link/link';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
data: SearchResultItem;
searchTerm: string;
}
const SearchResultTableItem = ({ data, searchTerm }: Props) => {
const content = (() => {
switch (data.type) {
case 'token': {
const name = data.name + (data.symbol ? ` (${ data.symbol })` : '');
return (
<>
<Td fontSize="sm">
<Flex alignItems="center">
<TokenLogo boxSize={ 6 } hash={ data.address } name={ data.name } flexShrink={ 0 }/>
<Link ml={ 2 } href={ link('token_index', { hash: data.address }) } fontWeight={ 700 } wordBreak="break-all">
<span dangerouslySetInnerHTML={{ __html: highlightText(name, searchTerm) }}/>
</Link>
</Flex>
</Td>
<Td fontSize="sm" verticalAlign="middle">
<Box whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ data.address }/>
</Box>
</Td>
</>
);
}
case 'contract':
case 'address': {
if (data.name) {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase();
return (
<>
<Td fontSize="sm">
<Flex alignItems="center" overflow="hidden">
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<Link href={ link('address_index', { id: data.address }) } fontWeight={ 700 } overflow="hidden" whiteSpace="nowrap">
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block">
<HashStringShortenDynamic hash={ data.address }/>
</Box>
</Link>
</Flex>
</Td>
<Td fontSize="sm" verticalAlign="middle">
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? data.name : highlightText(data.name, searchTerm) }}/>
</Td>
</>
);
}
return (
<Td colSpan={ 2 } fontSize="sm">
<Address>
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<mark>
<AddressLink hash={ data.address } type="address" fontWeight={ 700 }/>
</mark>
</Address>
</Td>
);
}
case 'block': {
const shouldHighlightHash = data.block_hash.toLowerCase() === searchTerm.toLowerCase();
return (
<>
<Td fontSize="sm">
<Flex alignItems="center">
<Icon as={ blockIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<Link fontWeight={ 700 } href={ link('block', { id: String(data.block_number) }) }>
<Box as={ shouldHighlightHash ? 'span' : 'mark' }>{ data.block_number }</Box>
</Link>
</Flex>
</Td>
<Td fontSize="sm" verticalAlign="middle">
<Box overflow="hidden" whiteSpace="nowrap" as={ shouldHighlightHash ? 'mark' : 'span' } display="block">
<HashStringShortenDynamic hash={ data.block_hash }/>
</Box>
</Td>
</>
);
}
case 'transaction': {
return (
<Td colSpan={ 2 } fontSize="sm">
<Flex alignItems="center">
<Icon as={ txIcon } boxSize={ 6 } mr={ 2 } color="gray.500"/>
<mark>
<AddressLink hash={ data.tx_hash } type="transaction" fontWeight={ 700 }/>
</mark>
</Flex>
</Td>
);
}
}
})();
return (
<Tr>
{ content }
<Td fontSize="sm" textTransform="capitalize" verticalAlign="middle">
<Text variant="secondary">
{ data.type }
</Text>
</Td>
</Tr>
);
};
export default React.memo(SearchResultTableItem);
......@@ -9,9 +9,10 @@ const runnerAnimation = keyframes`
interface Props {
className?: string;
text?: string;
}
const ContentLoader = ({ className }: Props) => {
const ContentLoader = ({ className, text }: Props) => {
return (
<Box display="inline-block" className={ className }>
<Box
......@@ -30,7 +31,9 @@ const ContentLoader = ({ className }: Props) => {
borderRadius: 'full',
}}
/>
<Text mt={ 6 } variant="secondary">Loading data, please wait... </Text>
<Text mt={ 6 } variant="secondary">
{ text || 'Loading data, please wait...' }
</Text>
</Box>
);
};
......
......@@ -5,18 +5,20 @@ import crossIcon from 'icons/cross.svg';
interface Props {
onClick: () => void;
className?: string;
}
const InputClearButton = ({ onClick }: Props) => {
const InputClearButton = ({ onClick, className }: Props) => {
const iconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
return (
<IconButton
className={ className }
colorScheme="gray"
aria-label="Clear input"
title="Clear input"
boxSize={ 6 }
icon={ <Icon as={ crossIcon } boxSize={ 4 } color={ iconColor }/> }
icon={ <Icon as={ crossIcon } boxSize={ 4 } color={ iconColor } focusable={ false }/> }
size="sm"
onClick={ onClick }
/>
......
......@@ -17,15 +17,15 @@ import NavigationDesktop from 'ui/snippets/navigation/NavigationDesktop';
interface Props {
children: React.ReactNode;
wrapChildren?: boolean;
hideMobileHeaderOnScrollDown?: boolean;
isHomePage?: boolean;
renderHeader?: () => React.ReactNode;
}
const Page = ({
children,
wrapChildren = true,
hideMobileHeaderOnScrollDown,
isHomePage,
renderHeader,
}: Props) => {
const nodeApiFetch = useFetch();
......@@ -71,7 +71,10 @@ const Page = ({
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Flex flexDir="column" flexGrow={ 1 } w={{ base: '100%', lg: 'auto' }}>
<Header isHomePage={ isHomePage } hideOnScrollDown={ hideMobileHeaderOnScrollDown }/>
{ renderHeader ?
renderHeader() :
<Header isHomePage={ isHomePage }/>
}
<ErrorBoundary renderErrorScreen={ renderErrorScreen }>
{ renderedChildren }
</ErrorBoundary>
......
......@@ -9,12 +9,11 @@ export type Props = {
onPrevPageClick: () => void;
resetPage: () => void;
hasNextPage: boolean;
hasPaginationParams?: boolean;
className?: string;
canGoBackwards: boolean;
}
const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNextPage, hasPaginationParams, className, canGoBackwards }: Props) => {
const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNextPage, className, canGoBackwards }: Props) => {
return (
<Flex
......@@ -26,7 +25,7 @@ const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNext
variant="outline"
size="sm"
onClick={ resetPage }
disabled={ !hasPaginationParams }
disabled={ page === 1 }
mr={ 4 }
>
First
......@@ -49,7 +48,6 @@ const Pagination = ({ page, onNextPageClick, onPrevPageClick, resetPage, hasNext
fontWeight={ 400 }
h={ 8 }
cursor="unset"
disabled={ hasPaginationParams && page === 1 }
>
{ page }
</Button>
......
......@@ -10,10 +10,20 @@ type AdData = {
thumbnail: string;
url: string;
cta_button: string;
impressionUrl: string;
impressionUrl?: string;
};
}
// const MOCK: AdData = {
// ad: {
// url: 'https://unsplash.com/s/photos/cute-kitten',
// thumbnail: 'https://placekitten.com/40/40',
// name: 'All about kitties',
// description_short: 'To see millions picture of cute kitties',
// cta_button: 'click here',
// },
// };
const CoinzillaTextAd = ({ className }: {className?: string}) => {
const [ adData, setAdData ] = React.useState<AdData | null>(null);
useEffect(() => {
......
......@@ -7,14 +7,14 @@ import type { AddressParam } from 'types/api/addressParams';
import AddressContractIcon from 'ui/shared/address/AddressContractIcon';
type Props = {
address: AddressParam;
address: Pick<AddressParam, 'is_contract' | 'hash' | 'implementation_name'>;
className?: string;
}
const AddressIcon = ({ address, className }: Props) => {
if (address.is_contract) {
return (
<AddressContractIcon/>
<AddressContractIcon className={ className }/>
);
}
......
......@@ -13,13 +13,15 @@ import ColorModeToggler from './ColorModeToggler';
type Props = {
isHomePage?: boolean;
hideOnScrollDown?: boolean;
renderSearchBar?: () => React.ReactNode;
}
const Header = ({ hideOnScrollDown, isHomePage }: Props) => {
const Header = ({ isHomePage, renderSearchBar }: Props) => {
const bgColor = useColorModeValue('white', 'black');
const scrollDirection = useScrollDirection();
const searchBar = renderSearchBar ? renderSearchBar() : <SearchBar/>;
return (
<>
<Box bgColor={ bgColor } display={{ base: 'block', lg: 'none' }}>
......@@ -37,13 +39,13 @@ const Header = ({ hideOnScrollDown, isHomePage }: Props) => {
zIndex="sticky2"
transitionProperty="box-shadow"
transitionDuration="slow"
boxShadow={ !hideOnScrollDown && scrollDirection === 'down' ? 'md' : 'none' }
boxShadow={ scrollDirection === 'down' ? 'md' : 'none' }
>
<Burger/>
<NetworkLogo/>
<ProfileMenuMobile/>
</Flex>
{ !isHomePage && <SearchBar withShadow={ !hideOnScrollDown }/> }
{ !isHomePage && searchBar }
</Box>
<Box
paddingX={ 12 }
......@@ -61,7 +63,7 @@ const Header = ({ hideOnScrollDown, isHomePage }: Props) => {
paddingBottom="52px"
>
<Box width="100%">
<SearchBar/>
{ searchBar }
</Box>
<ColorModeToggler/>
<ProfileMenuDesktop/>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as textAdMock from 'mocks/ad/textAd';
import * as searchMock from 'mocks/search/index';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import SearchBar from './SearchBar';
test.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
});
test('search by 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.token1,
searchMock.token2,
searchMock.contract1,
],
}),
}));
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 address hash +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + `?q=${ searchMock.address1.address }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.address1,
],
}),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.address1.address);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by block number +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + `?q=${ searchMock.block1.block_number }`;
await page.route(buildApiUrl('search') + `?q=${ searchMock.block1.block_number }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.block1,
],
}),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(String(searchMock.block1.block_number));
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by block hash +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + `?q=${ searchMock.block1.block_hash }`;
await page.route(buildApiUrl('search') + `?q=${ searchMock.block1.block_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.block1,
],
}),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.block1.block_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by tx hash +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + `?q=${ searchMock.tx1.tx_hash }`;
await page.route(buildApiUrl('search') + `?q=${ searchMock.tx1.tx_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.tx1,
],
}),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
import type { ChangeEvent, FormEvent } from 'react';
import { Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure } from '@chakra-ui/react';
import _debounce from 'lodash/debounce';
import type { FormEvent, FocusEvent } from 'react';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import link from 'lib/link/link';
import SearchBarDesktop from './SearchBarDesktop';
import SearchBarMobile from './SearchBarMobile';
import SearchBarMobileHome from './SearchBarMobileHome';
import SearchBarInput from './SearchBarInput';
import SearchBarSuggest from './SearchBarSuggest';
import useSearchQuery from './useSearchQuery';
type Props = {
withShadow?: boolean;
isHomepage?: boolean;
}
const SearchBar = ({ isHomepage, withShadow }: Props) => {
const [ value, setValue ] = React.useState('');
const SearchBar = ({ isHomepage }: Props) => {
const { isOpen, onClose, onOpen } = useDisclosure();
const inputRef = React.useRef<HTMLFormElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const menuWidth = React.useRef<number>(0);
const isMobile = useIsMobile();
const handleChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
}, []);
const { searchTerm, handleSearchTermChange, query } = useSearchQuery();
const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const url = link('search_results', undefined, { q: value });
window.location.assign(url);
}, [ value ]);
if (searchTerm) {
const url = link('search_results', undefined, { q: searchTerm });
window.location.assign(url);
}
}, [ searchTerm ]);
const handleFocus = React.useCallback(() => {
onOpen();
}, [ onOpen ]);
const handelHide = React.useCallback(() => {
onClose();
inputRef.current?.querySelector('input')?.blur();
}, [ onClose ]);
const handleBlur = React.useCallback((event: FocusEvent<HTMLFormElement>) => {
const isFocusInMenu = menuRef.current?.contains(event.relatedTarget);
const isFocusInInput = inputRef.current?.contains(event.relatedTarget);
if (!isFocusInMenu && !isFocusInInput) {
onClose();
}
}, [ onClose ]);
const handleClear = React.useCallback(() => {
handleSearchTermChange('');
inputRef.current?.querySelector('input')?.focus();
}, [ handleSearchTermChange ]);
const menuPaddingX = isMobile && !isHomepage ? 32 : 0;
const calculateMenuWidth = React.useCallback(() => {
menuWidth.current = (inputRef.current?.getBoundingClientRect().width || 0) - menuPaddingX;
}, [ menuPaddingX ]);
React.useEffect(() => {
const inputEl = inputRef.current;
if (!inputEl) {
return;
}
calculateMenuWidth();
const resizeHandler = _debounce(calculateMenuWidth, 200);
const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(inputRef.current);
return function cleanup() {
resizeObserver.unobserve(inputEl);
};
}, [ calculateMenuWidth ]);
return (
<>
<SearchBarDesktop onChange={ handleChange } onSubmit={ handleSubmit } isHomepage={ isHomepage }/>
{ !isHomepage && (
<SearchBarMobile
onChange={ handleChange }
onSubmit={ handleSubmit }
withShadow={ withShadow }
/>
) }
{ isHomepage && (
<SearchBarMobileHome
onChange={ handleChange }
<Popover
isOpen={ isOpen && searchTerm.trim().length > 0 }
autoFocus={ false }
onClose={ onClose }
placement="bottom-start"
offset={ isMobile && !isHomepage ? [ 16, -12 ] : undefined }
isLazy
>
<PopoverTrigger>
<SearchBarInput
ref={ inputRef }
onChange={ handleSearchTermChange }
onSubmit={ handleSubmit }
onFocus={ handleFocus }
onBlur={ handleBlur }
onHide={ handelHide }
onClear={ handleClear }
isHomepage={ isHomepage }
value={ searchTerm }
/>
) }
</>
</PopoverTrigger>
<PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }>
<PopoverBody py={ 6 }>
<SearchBarSuggest query={ query } searchTerm={ searchTerm }/>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
......
import { InputGroup, Input, InputLeftElement, Icon, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import searchIcon from 'icons/search.svg';
interface Props {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
isHomepage?: boolean;
}
const SearchBarDesktop = ({ onChange, onSubmit, isHomepage }: Props) => {
return (
<chakra.form
noValidate
onSubmit={ onSubmit }
display={{ base: 'none', lg: 'block' }}
w="100%"
backgroundColor={ isHomepage ? 'white' : 'none' }
borderRadius="base"
>
<InputGroup>
<InputLeftElement w={ 6 } ml={ 4 }>
<Icon as={ searchIcon } boxSize={ 6 } color={ useColorModeValue('blackAlpha.600', 'whiteAlpha.600') }/>
</InputLeftElement>
<Input
// paddingInlineStart="50px"
pl="50px"
placeholder="Search by addresses / transactions / block / token... "
ml="1px"
onChange={ onChange }
border={ isHomepage ? 'none' : '2px solid' }
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
_focusWithin={{ _placeholder: { color: 'gray.300' } }}
color={ useColorModeValue('black', 'white') }
/>
</InputGroup>
</chakra.form>
);
};
export default React.memo(SearchBarDesktop);
import { InputGroup, Input, InputLeftElement, Icon, chakra, useColorModeValue, forwardRef, InputRightElement } from '@chakra-ui/react';
import throttle from 'lodash/throttle';
import React from 'react';
import type { ChangeEvent, FormEvent, FocusEvent } from 'react';
import searchIcon from 'icons/search.svg';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
import useIsMobile from 'lib/hooks/useIsMobile';
import InputClearButton from 'ui/shared/InputClearButton';
interface Props {
onChange: (value: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onBlur?: (event: FocusEvent<HTMLFormElement>) => void;
onFocus?: () => void;
onHide?: () => void;
onClear: () => void;
isHomepage?: boolean;
value: string;
}
const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHide, onClear, value }: Props, ref: React.ForwardedRef<HTMLFormElement>) => {
const [ isSticky, setIsSticky ] = React.useState(false);
const scrollDirection = useScrollDirection();
const isMobile = useIsMobile();
const handleScroll = React.useCallback(() => {
if (window.pageYOffset !== 0) {
setIsSticky(true);
} else {
setIsSticky(false);
}
}, [ ]);
const handleChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
}, [ onChange ]);
React.useEffect(() => {
if (!isMobile || isHomepage) {
return;
}
const throttledHandleScroll = throttle(handleScroll, 300);
window.addEventListener('scroll', throttledHandleScroll);
return () => {
window.removeEventListener('scroll', throttledHandleScroll);
};
// replicate componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isMobile ]);
const bgColor = useColorModeValue('white', 'black');
const transformMobile = scrollDirection !== 'down' ? 'translateY(0)' : 'translateY(-100%)';
React.useEffect(() => {
if (isMobile && scrollDirection === 'down') {
onHide?.();
}
}, [ scrollDirection, onHide, isMobile ]);
return (
<chakra.form
ref={ ref }
noValidate
onSubmit={ onSubmit }
onBlur={ onBlur }
onFocus={ onFocus }
w="100%"
backgroundColor={ isHomepage ? 'white' : bgColor }
borderRadius={{ base: isHomepage ? 'base' : 'none', lg: 'base' }}
position={{ base: isHomepage ? 'static' : 'fixed', lg: 'static' }}
top={{ base: isHomepage ? 0 : 55, lg: 0 }}
left="0"
zIndex={{ base: isHomepage ? 'auto' : 'sticky1', lg: 'auto' }}
paddingX={{ base: isHomepage ? 0 : 4, lg: 0 }}
paddingTop={{ base: isHomepage ? 0 : 1, lg: 0 }}
paddingBottom={{ base: isHomepage ? 0 : 4, lg: 0 }}
boxShadow={ scrollDirection !== 'down' && isSticky ? 'md' : 'none' }
transform={{ base: isHomepage ? 'none' : transformMobile, lg: 'none' }}
transitionProperty="transform,box-shadow"
transitionDuration="slow"
>
<InputGroup size={{ base: isHomepage ? 'md' : 'sm', lg: 'md' }}>
<InputLeftElement w={{ base: isHomepage ? 6 : 4, lg: 6 }} ml={{ base: isHomepage ? 4 : 3, lg: 4 }} h="100%">
<Icon as={ searchIcon } boxSize={{ base: isHomepage ? 6 : 4, lg: 6 }} color={ useColorModeValue('blackAlpha.600', 'whiteAlpha.600') }/>
</InputLeftElement>
<Input
pl={{ base: isHomepage ? '50px' : '38px', lg: '50px' }}
sx={{
'@media screen and (max-width: 999px)': {
paddingLeft: isHomepage ? '50px' : '38px',
paddingRight: '36px',
},
'@media screen and (min-width: 1001px)': {
paddingRight: '36px',
},
}}
placeholder={ isMobile ? 'Search by addresses / ... ' : 'Search by addresses / transactions / block / token... ' }
onChange={ handleChange }
border={ isHomepage ? 'none' : '2px solid' }
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
_focusWithin={{ _placeholder: { color: 'gray.300' } }}
color={ useColorModeValue('black', 'white') }
value={ value }
/>
{ value && (
<InputRightElement top={{ base: 2, lg: '18px' }} right={ 2 }>
<InputClearButton onClick={ onClear }/>
</InputRightElement>
) }
</InputGroup>
</chakra.form>
);
};
export default React.memo(forwardRef(SearchBarInput));
import { InputGroup, Input, InputLeftElement, Icon, useColorModeValue, chakra } from '@chakra-ui/react';
import throttle from 'lodash/throttle';
import React from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import searchIcon from 'icons/search.svg';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
const TOP = 55;
interface Props {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
withShadow?: boolean;
}
const SearchBarMobile = ({ onChange, onSubmit, withShadow }: Props) => {
const [ isSticky, setIsSticky ] = React.useState(false);
const scrollDirection = useScrollDirection();
const handleScroll = React.useCallback(() => {
if (window.pageYOffset !== 0) {
setIsSticky(true);
} else {
setIsSticky(false);
}
}, []);
React.useEffect(() => {
const throttledHandleScroll = throttle(handleScroll, 300);
window.addEventListener('scroll', throttledHandleScroll);
return () => {
window.removeEventListener('scroll', throttledHandleScroll);
};
// replicate componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const searchIconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
const inputBorderColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
const bgColor = useColorModeValue('white', 'black');
return (
<chakra.form
noValidate
onSubmit={ onSubmit }
paddingX={ 4 }
paddingTop={ 1 }
paddingBottom={ 2 }
position="fixed"
top={ `${ TOP }px` }
left="0"
zIndex="sticky1"
bgColor={ bgColor }
transform={ scrollDirection !== 'down' ? 'translateY(0)' : 'translateY(-100%)' }
transitionProperty="transform,box-shadow"
transitionDuration="slow"
display={{ base: 'block', lg: 'none' }}
w="100%"
boxShadow={ withShadow && scrollDirection !== 'down' && isSticky ? 'md' : 'none' }
>
<InputGroup size="sm">
<InputLeftElement >
<Icon as={ searchIcon } boxSize={ 4 } color={ searchIconColor }/>
</InputLeftElement>
<Input
paddingInlineStart="38px"
placeholder="Search by addresses / ... "
ml="1px"
onChange={ onChange }
borderColor={ inputBorderColor }
/>
</InputGroup>
</chakra.form>
);
};
export default React.memo(SearchBarMobile);
import { InputGroup, Input, InputLeftElement, Icon, LightMode, chakra } from '@chakra-ui/react';
import React from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import searchIcon from 'icons/search.svg';
interface Props {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
backgroundColor?: string;
}
const SearchBarMobileHome = ({ onChange, onSubmit }: Props) => {
const commonProps = {
noValidate: true,
onSubmit: onSubmit,
width: '100%',
display: { base: 'block', lg: 'none' },
};
return (
<LightMode>
<chakra.form
{ ...commonProps }
bgColor="white"
h="60px"
borderRadius="10px"
>
<InputGroup size="md">
<InputLeftElement >
<Icon as={ searchIcon } boxSize={ 6 } color="blackAlpha.600"/>
</InputLeftElement>
<Input
paddingInlineStart="38px"
placeholder="Search by addresses / ... "
ml="1px"
onChange={ onChange }
border="none"
color="black"
/>
</InputGroup>
</chakra.form>
</LightMode>
);
};
export default SearchBarMobileHome;
import { Text, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { SearchResult } 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 { Props as PaginationProps } from 'ui/shared/Pagination';
import SearchBarSuggestItem from './SearchBarSuggestItem';
interface Props {
query: UseQueryResult<SearchResult> & {
pagination: PaginationProps;
};
searchTerm: string;
}
const SearchBarSuggest = ({ query, searchTerm }: Props) => {
const isMobile = useIsMobile();
const content = (() => {
if (query.isLoading) {
return <ContentLoader text="We are searching, please wait... "/>;
}
if (query.isError) {
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
}
const num = query.data.next_page_params ? '50+' : query.data.items.length;
const resultText = query.data.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>
{ query.data.items.map((item, index) => <SearchBarSuggestItem key={ index } data={ item } isMobile={ isMobile } searchTerm={ searchTerm }/>) }
</>
);
})();
const dividerColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<>
{ !isMobile && <TextAd pb={ 4 } mb={ 5 } borderColor={ dividerColor } borderBottomWidth="1px"/> }
{ content }
</>
);
};
export default SearchBarSuggest;
import { chakra, Text, Flex, useColorModeValue, Icon, Box } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultItem } from 'types/api/search';
import blockIcon from 'icons/block.svg';
import txIcon from 'icons/transactions.svg';
import highlightText from 'lib/highlightText';
import link from 'lib/link/link';
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;
}
const SearchBarSuggestItem = ({ data, isMobile, searchTerm }: Props) => {
const url = (() => {
switch (data.type) {
case 'token': {
return link('token_index', { hash: data.address });
}
case 'contract':
case 'address': {
return link('address_index', { id: data.address });
}
case 'transaction': {
return link('tx', { id: data.tx_hash });
}
case 'block': {
return link('block', { id: String(data.block_number) });
}
}
})();
const firstRow = (() => {
switch (data.type) {
case 'token': {
const name = data.name + (data.symbol ? ` (${ data.symbol })` : '');
return (
<>
<TokenLogo boxSize={ 6 } hash={ data.address } name={ data.name } 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 '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': {
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 (
<chakra.a
py={ 3 }
px={ 1 }
display="flex"
flexDir="column"
rowGap={ 2 }
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderBottomWidth="1px"
_last={{
borderBottomWidth: '0',
}}
_hover={{
bgColor: useColorModeValue('blue.50', 'gray.800'),
}}
fontSize="sm"
href={ url }
_first={{
mt: 2,
}}
>
<Flex display="flex" alignItems="center">
{ firstRow }
</Flex>
{ secondRow }
</chakra.a>
);
};
export default React.memo(SearchBarSuggestItem);
import { useRouter } from 'next/router';
import React from 'react';
import useDebounce from 'lib/hooks/useDebounce';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import useUpdateValueEffect from 'lib/hooks/useUpdateValueEffect';
export default function useSearchQuery(isSearchPage = false) {
const router = useRouter();
const initialValue = isSearchPage ? String(router.query.q || '') : '';
const [ searchTerm, setSearchTerm ] = React.useState(initialValue);
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const query = useQueryWithPages({
resourceName: 'search',
filters: { q: debouncedSearchTerm },
options: { enabled: debouncedSearchTerm.trim().length > 0 },
});
useUpdateValueEffect(() => {
if (isSearchPage) {
query.onFilterChange({ q: debouncedSearchTerm });
}
}, debouncedSearchTerm);
return {
searchTerm,
debouncedSearchTerm,
handleSearchTermChange: setSearchTerm,
query,
};
}
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