Commit 4bf1d410 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge branch 'main' into tabs-counters

parents 064c44d6 d7b7b83b
......@@ -41,7 +41,7 @@ import type { L2TxnBatchesResponse } from 'types/api/l2TxnBatches';
import type { L2WithdrawalsResponse } from 'types/api/l2Withdrawals';
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { RawTracesResponse } from 'types/api/rawTrace';
import type { SearchRedirectResult, SearchResult, SearchResultFilters } from 'types/api/search';
import type { SearchRedirectResult, SearchResult, SearchResultFilters, SearchResultItem } from 'types/api/search';
import type { Counters, StatsCharts, StatsChart, HomeStats } from 'types/api/stats';
import type {
TokenCounters,
......@@ -425,6 +425,10 @@ export const RESOURCES = {
},
// SEARCH
quick_search: {
path: '/api/v2/search/quick',
filterFields: [ 'q' ],
},
search: {
path: '/api/v2/search',
filterFields: [ 'q' ],
......@@ -603,6 +607,7 @@ Q extends 'token_instance_transfers' ? TokenInstanceTransferResponse :
Q extends 'token_instance_holders' ? TokenHolders :
Q extends 'token_inventory' ? TokenInventoryResponse :
Q extends 'tokens' ? TokensResponse :
Q extends 'quick_search' ? Array<SearchResultItem> :
Q extends 'search' ? SearchResult :
Q extends 'search_check_redirect' ? SearchRedirectResult :
Q extends 'contract' ? SmartContract :
......
......@@ -11,6 +11,7 @@ export const token1: SearchResultToken = {
token_type: 'ERC-721',
total_supply: '10000001',
exchange_rate: null,
is_verified_via_admin_panel: true,
is_smart_contract_verified: true,
};
......@@ -25,6 +26,7 @@ export const token2: SearchResultToken = {
token_type: 'ERC-20',
total_supply: '10000001',
exchange_rate: '1.11',
is_verified_via_admin_panel: false,
is_smart_contract_verified: false,
};
......
......@@ -10,6 +10,7 @@ export const SEARCH_RESULT_ITEM: SearchResultItem = {
token_url: '/token/0x3714A8C7824B22271550894f7555f0a672f97809',
type: 'token',
icon_url: null,
is_verified_via_admin_panel: false,
is_smart_contract_verified: false,
exchange_rate: '1.11',
total_supply: null,
......
......@@ -13,6 +13,7 @@ export interface SearchResultToken {
token_type: TokenType;
exchange_rate: string | null;
total_supply: string | null;
is_verified_via_admin_panel: boolean;
is_smart_contract_verified: boolean;
}
......
......@@ -21,7 +21,7 @@ import useSearchQuery from 'ui/snippets/searchBar/useSearchQuery';
const SearchResultsPageContent = () => {
const router = useRouter();
const { query, redirectCheckQuery, searchTerm, debouncedSearchTerm, handleSearchTermChange } = useSearchQuery(true);
const { query, redirectCheckQuery, searchTerm, debouncedSearchTerm, handleSearchTermChange } = useSearchQuery();
const { data, isError, isPlaceholderData, pagination } = query;
const [ showContent, setShowContent ] = React.useState(false);
......
......@@ -7,6 +7,7 @@ import { route } from 'nextjs-routes';
import labelIcon from 'icons/publictags.svg';
import iconSuccess from 'icons/status/success.svg';
import verifiedToken from 'icons/verified_token.svg';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText';
import * as mixpanel from 'lib/mixpanel/index';
......@@ -49,7 +50,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
const name = data.name + (data.symbol ? ` (${ data.symbol })` : '');
return (
<Flex alignItems="flex-start" flexGrow={ 1 } overflow="hidden">
<Flex alignItems="center" overflow="hidden">
<TokenLogo boxSize={ 6 } data={ data } flexShrink={ 0 } isLoading={ isLoading }/>
<LinkInternal
ml={ 2 }
......@@ -69,6 +70,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
textOverflow="ellipsis"
/>
</LinkInternal>
{ data.is_verified_via_admin_panel && <Icon as={ verifiedToken } boxSize={ 4 } ml={ 1 } color="green.500"/> }
</Flex>
);
}
......
......@@ -7,6 +7,7 @@ import { route } from 'nextjs-routes';
import labelIcon from 'icons/publictags.svg';
import iconSuccess from 'icons/status/success.svg';
import verifiedToken from 'icons/verified_token.svg';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText';
import * as mixpanel from 'lib/mixpanel/index';
......@@ -69,6 +70,7 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
dangerouslySetInnerHTML={{ __html: highlightText(name, searchTerm) }}
/>
</LinkInternal>
{ data.is_verified_via_admin_panel && <Icon as={ verifiedToken } boxSize={ 4 } ml={ 1 } color="green.500"/> }
</Flex>
</Td>
<Td fontSize="sm" verticalAlign="middle">
......
......@@ -30,15 +30,13 @@ test.beforeEach(async({ page }) => {
});
test('search by token name +@mobile +@dark-mode', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.token1,
searchMock.token2,
],
}),
body: JSON.stringify([
searchMock.token1,
searchMock.token2,
]),
}));
await mount(
......@@ -53,14 +51,12 @@ test('search by token name +@mobile +@dark-mode', async({ mount, page }) => {
});
test('search by contract name +@mobile +@dark-mode', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.contract1,
],
}),
body: JSON.stringify([
searchMock.contract1,
]),
}));
await mount(
......@@ -75,16 +71,14 @@ test('search by contract name +@mobile +@dark-mode', async({ mount, page }) =>
});
test('search by name homepage +@dark-mode', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.token1,
searchMock.token2,
searchMock.contract1,
],
}),
body: JSON.stringify([
searchMock.token1,
searchMock.token2,
searchMock.contract1,
]),
}));
await mount(
......@@ -101,14 +95,12 @@ test('search by name homepage +@dark-mode', async({ mount, page }) => {
});
test('search by tag +@mobile +@dark-mode', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.label1,
],
}),
body: JSON.stringify([
searchMock.label1,
]),
}));
await mount(
......@@ -123,14 +115,12 @@ test('search by tag +@mobile +@dark-mode', async({ mount, page }) => {
});
test('search by address hash +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + `?q=${ searchMock.address1.address }`;
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.address1.address }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.address1,
],
}),
body: JSON.stringify([
searchMock.address1,
]),
}));
await mount(
......@@ -145,14 +135,12 @@ test('search by address hash +@mobile', async({ mount, page }) => {
});
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({
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.block1.block_number }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.block1,
],
}),
body: JSON.stringify([
searchMock.block1,
]),
}));
await mount(
......@@ -167,14 +155,12 @@ test('search by block number +@mobile', async({ mount, page }) => {
});
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({
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.block1.block_hash }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.block1,
],
}),
body: JSON.stringify([
searchMock.block1,
]),
}));
await mount(
......@@ -189,14 +175,12 @@ test('search by block hash +@mobile', async({ mount, page }) => {
});
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({
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.tx1.tx_hash }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.tx1,
],
}),
body: JSON.stringify([
searchMock.tx1,
]),
}));
await mount(
......@@ -211,17 +195,15 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
});
test('search with view all link', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.token1,
searchMock.token2,
searchMock.contract1,
],
next_page_params: { foo: 'bar' },
}),
body: JSON.stringify([
searchMock.token1,
searchMock.token2,
searchMock.contract1,
...Array(47).fill(searchMock.contract1),
]),
}));
await mount(
......@@ -237,25 +219,23 @@ test('search with view all link', async({ mount, page }) => {
});
test('scroll suggest to category', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
],
}),
body: JSON.stringify([
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
searchMock.token1,
searchMock.token2,
searchMock.contract1,
]),
}));
await mount(
......@@ -287,15 +267,12 @@ test.describe('with apps', () => {
const MARKETPLACE_CONFIG_URL = 'https://localhost:3000/marketplace-config.json';
test('default view +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + '?q=o';
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.token1,
],
next_page_params: { foo: 'bar' },
}),
body: JSON.stringify([
searchMock.token1,
]),
}));
await page.route(MARKETPLACE_CONFIG_URL, (route) => route.fulfill({
......
......@@ -15,7 +15,7 @@ import LinkInternal from 'ui/shared/LinkInternal';
import SearchBarInput from './SearchBarInput';
import SearchBarRecentKeywords from './SearchBarRecentKeywords';
import SearchBarSuggest from './SearchBarSuggest/SearchBarSuggest';
import useSearchQuery from './useSearchQuery';
import useQuickSearchQuery from './useQuickSearchQuery';
type Props = {
isHomepage?: boolean;
......@@ -34,7 +34,7 @@ const SearchBar = ({ isHomepage }: Props) => {
const recentSearchKeywords = getRecentSearchKeywords();
const { searchTerm, debouncedSearchTerm, handleSearchTermChange, query, pathname } = useSearchQuery();
const { searchTerm, debouncedSearchTerm, handleSearchTermChange, query, pathname } = useQuickSearchQuery();
const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
......@@ -160,7 +160,7 @@ const SearchBar = ({ isHomepage }: Props) => {
) }
</Box>
</PopoverBody>
{ searchTerm.trim().length > 0 && query.data?.next_page_params && (
{ searchTerm.trim().length > 0 && query.data && query.data.length >= 50 && (
<PopoverFooter>
<LinkInternal
href={ route({ pathname: '/search-results', query: { q: searchTerm } }) }
......
import { Box, Tab, TabList, Tabs, Text, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import throttle from 'lodash/throttle';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import type { SearchResultItem } from 'types/api/search';
import type { ResourceError } from 'lib/api/resources';
import useIsMobile from 'lib/hooks/useIsMobile';
import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps';
import TextAd from 'ui/shared/ad/TextAd';
import ContentLoader from 'ui/shared/ContentLoader';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import type { ApiCategory, ItemsCategoriesMap } from 'ui/shared/search/utils';
import { getItemCategory, searchCategories } from 'ui/shared/search/utils';
......@@ -15,7 +18,7 @@ import SearchBarSuggestApp from './SearchBarSuggestApp';
import SearchBarSuggestItem from './SearchBarSuggestItem';
interface Props {
query: QueryWithPagesResult<'search'>;
query: UseQueryResult<Array<SearchResultItem>, ResourceError<unknown>>;
searchTerm: string;
onItemClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
containerId: string;
......@@ -33,7 +36,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
const handleScroll = React.useCallback(() => {
const container = document.getElementById(containerId);
if (!container || !query.data?.items.length) {
if (!container || !query.data?.length) {
return;
}
const topLimit = container.getBoundingClientRect().y + (tabsRef.current?.clientHeight || 0) + 24;
......@@ -47,7 +50,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
break;
}
}
}, [ containerId, query.data?.items ]);
}, [ containerId, query.data ]);
React.useEffect(() => {
const container = document.getElementById(containerId);
......@@ -63,11 +66,11 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
}, [ containerId, handleScroll ]);
const itemsGroups = React.useMemo(() => {
if (!query.data?.items && !marketplaceApps.displayedApps) {
if (!query.data && !marketplaceApps.displayedApps) {
return {};
}
const map: Partial<ItemsCategoriesMap> = {};
query.data?.items.forEach(item => {
query.data?.forEach(item => {
const cat = getItemCategory(item) as ApiCategory;
if (cat) {
if (cat in map) {
......@@ -81,7 +84,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
map.app = marketplaceApps.displayedApps;
}
return map;
}, [ query.data?.items, marketplaceApps.displayedApps ]);
}, [ query.data, marketplaceApps.displayedApps ]);
const scrollToCategory = React.useCallback((index: number) => () => {
setTabIndex(index);
......@@ -104,7 +107,7 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
}
if (!query.data.items || query.data.items.length === 0) {
if (!query.data || query.data.length === 0) {
return <Text>No results found.</Text>;
}
......
......@@ -4,6 +4,7 @@ import React from 'react';
import type { SearchResultToken } from 'types/api/search';
import iconSuccess from 'icons/status/success.svg';
import verifiedToken from 'icons/verified_token.svg';
import highlightText from 'lib/highlightText';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import TokenLogo from 'ui/shared/TokenLogo';
......@@ -16,6 +17,7 @@ interface Props {
const SearchBarSuggestToken = ({ data, isMobile, searchTerm }: Props) => {
const icon = <TokenLogo boxSize={ 6 } data={ data }/>;
const verifiedIcon = <Icon as={ verifiedToken } boxSize={ 4 } color="green.500"/>;
const name = (
<Text
fontWeight={ 700 }
......@@ -49,7 +51,10 @@ const SearchBarSuggestToken = ({ data, isMobile, searchTerm }: Props) => {
<>
<Flex alignItems="center" gap={ 2 }>
{ icon }
{ name }
<Flex alignItems="center" gap={ 1 }>
{ name }
{ data.is_verified_via_admin_panel && verifiedIcon }
</Flex>
</Flex>
<Grid templateColumns={ templateCols } alignItems="center" gap={ 2 }>
<Flex alignItems="center" overflow="hidden">
......@@ -65,7 +70,10 @@ const SearchBarSuggestToken = ({ data, isMobile, searchTerm }: Props) => {
return (
<Grid templateColumns="24px 200px 1fr auto" gap={ 2 }>
{ icon }
{ name }
<Flex alignItems="center" gap={ 1 }>
{ name }
{ data.is_verified_via_admin_panel && verifiedIcon }
</Flex>
<Flex alignItems="center" overflow="hidden">
{ address }
{ contractVerifiedIcon }
......
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import useDebounce from 'lib/hooks/useDebounce';
export default function useQuickSearchQuery() {
const router = useRouter();
const [ searchTerm, setSearchTerm ] = React.useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const pathname = router.pathname;
const query = useApiQuery('quick_search', {
queryParams: { q: debouncedSearchTerm },
queryOptions: { enabled: debouncedSearchTerm.trim().length > 0 },
});
const redirectCheckQuery = useApiQuery('search_check_redirect', {
// on pages with regular search bar we check redirect on every search term change
// in order to prepend its result to suggest list since this resource is much faster than regular search
queryParams: { q: debouncedSearchTerm },
queryOptions: { enabled: Boolean(debouncedSearchTerm) },
});
return React.useMemo(() => ({
searchTerm,
debouncedSearchTerm,
handleSearchTermChange: setSearchTerm,
query,
redirectCheckQuery,
pathname,
}), [ debouncedSearchTerm, pathname, query, redirectCheckQuery, searchTerm ]);
}
......@@ -9,10 +9,10 @@ import { SEARCH_RESULT_ITEM, SEARCH_RESULT_NEXT_PAGE_PARAMS } from 'stubs/search
import { generateListStub } from 'stubs/utils';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
export default function useSearchQuery(isSearchPage = false) {
export default function useSearchQuery() {
const router = useRouter();
const q = React.useRef(getQueryParamString(router.query.q));
const initialValue = isSearchPage ? q.current : '';
const initialValue = q.current;
const [ searchTerm, setSearchTerm ] = React.useState(initialValue);
......@@ -24,24 +24,18 @@ export default function useSearchQuery(isSearchPage = false) {
filters: { q: debouncedSearchTerm },
options: {
enabled: debouncedSearchTerm.trim().length > 0,
placeholderData: isSearchPage ?
generateListStub<'search'>(SEARCH_RESULT_ITEM, 50, { next_page_params: SEARCH_RESULT_NEXT_PAGE_PARAMS }) :
undefined,
placeholderData: generateListStub<'search'>(SEARCH_RESULT_ITEM, 50, { next_page_params: SEARCH_RESULT_NEXT_PAGE_PARAMS }),
},
});
const redirectCheckQuery = useApiQuery('search_check_redirect', {
// on search result page we check redirect only once on mount
// on pages with regular search bar we check redirect on every search term change
// in order to prepend its result to suggest list since this resource is much faster than regular search
queryParams: { q: isSearchPage ? q.current : debouncedSearchTerm },
queryOptions: { enabled: Boolean(isSearchPage ? q.current : debouncedSearchTerm) },
queryParams: { q: q.current },
queryOptions: { enabled: Boolean(q.current) },
});
useUpdateValueEffect(() => {
if (isSearchPage) {
query.onFilterChange({ q: debouncedSearchTerm });
}
query.onFilterChange({ q: debouncedSearchTerm });
}, debouncedSearchTerm);
return React.useMemo(() => ({
......
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