Commit 232e1793 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Render Token page `<h1>` on the server (#1862)

* extract TokenPageTitle into separate component

* remove dynamic import for TokenPage

* add initialData to tokenQuery

* add ENV variable

* don't update metadata on client

* fix test
parent faa24a37
......@@ -10,6 +10,9 @@ const meta = Object.freeze({
imageUrl: app.baseUrl + (getExternalAssetFilePath('NEXT_PUBLIC_OG_IMAGE_URL') || defaultImageUrl),
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED') === 'true',
},
seo: {
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED') === 'true',
},
});
export default meta;
......@@ -55,3 +55,5 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true
......@@ -604,6 +604,7 @@ const schema = yup
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: yup.boolean(),
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED: yup.boolean(),
NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
......
......@@ -42,6 +42,7 @@ NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_OG_DESCRIPTION='Hello world!'
NEXT_PUBLIC_OG_IMAGE_URL=https://example.com/image.png
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://blockscout.com','text':'Blockscout'}]
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE=true
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global
......
......@@ -172,7 +172,7 @@ By default, the app has generic favicon. You can override this behavior by provi
### Meta
Settings for meta tags and OG tags
Settings for meta tags, OG tags and SEO
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
......@@ -180,6 +180,7 @@ Settings for meta tags and OG tags
| NEXT_PUBLIC_OG_DESCRIPTION | `string` | Custom OG description | - | - | `Blockscout is the #1 open-source blockchain explorer available today. 100+ chains and counting rely on Blockscout data availability, APIs, and ecosystem tools to support their networks.` |
| NEXT_PUBLIC_OG_IMAGE_URL | `string` | OG image url. Minimum image size is 200 x 20 pixels (recommended: 1200 x 600); maximum supported file size is 8 MB; 2:1 aspect ratio; supported formats: image/jpeg, image/gif, image/png | - | `static/og_placeholder.png` | `https://placekitten.com/1200/600` |
| NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED | `boolean` | Set to `true` to populate OG tags (title, description) with API data for social preview robot requests | - | `false` | `true` |
| NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED | `boolean` | Set to `true` to pre-render page titles (e.g Token page) on the server side and inject page h1-tag to the markup before it is sent to the browser. | - | `false` | `true` |
&nbsp;
......
import React, { createContext, useContext } from 'react';
import type { Route } from 'nextjs-routes';
import type { Props as PageProps } from 'nextjs/getServerSideProps';
type Props = {
......@@ -23,6 +24,6 @@ export function AppContextProvider({ children, pageProps }: Props) {
);
}
export function useAppContext() {
return useContext(AppContext);
export function useAppContext<Pathname extends Route['pathname'] = never>() {
return useContext<PageProps<Pathname>>(AppContext);
}
import type { TokenInfo } from 'types/api/token';
import type { Route } from 'nextjs-routes';
/* eslint-disable @typescript-eslint/indent */
export type ApiData<Pathname extends Route['pathname']> =
(
Pathname extends '/address/[hash]' ? { domain_name: string } :
Pathname extends '/token/[hash]' ? { symbol: string } :
Pathname extends '/token/[hash]' ? TokenInfo :
Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } :
Pathname extends '/apps/[id]' ? { app_name: string } :
never
......
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Route } from 'nextjs-routes';
......@@ -11,8 +10,7 @@ import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
const Token = dynamic(() => import('ui/pages/Token'), { ssr: false });
import Token from 'ui/pages/Token';
const pathname: Route['pathname'] = '/token/[hash]';
......@@ -29,19 +27,18 @@ export default Page;
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
if ('props' in baseResponse) {
if (
config.meta.seo.enhancedDataEnabled ||
(config.meta.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview')
) {
const tokenData = await fetchApi({
resource: 'token',
pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000,
timeout: 500,
});
(await baseResponse.props).apiData = tokenData && tokenData.symbol ? {
symbol: tokenData.symbol,
} : null;
(await baseResponse.props).apiData = tokenData ?? null;
}
}
......
import React from 'react';
import config from 'configs/app';
import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses';
import { token as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters, bridgedTokenA } from 'mocks/tokens/tokenInfo';
......@@ -10,9 +11,12 @@ import * as configs from 'playwright/utils/configs';
import Token from './Token';
const hash = tokenInfo.address;
const chainId = config.chain.id;
const hooksConfig = {
router: {
query: { hash: '1', tab: 'token_transfers' },
query: { hash, tab: 'token_transfers' },
isReady: true,
},
};
......@@ -22,17 +26,17 @@ const hooksConfig = {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async({ mockApiResponse }) => {
await mockApiResponse('token', tokenInfo, { pathParams: { hash: '1' } });
await mockApiResponse('address', contract, { pathParams: { hash: '1' } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash: '1' } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash: '1' } });
await mockApiResponse('token', tokenInfo, { pathParams: { hash } });
await mockApiResponse('address', contract, { pathParams: { hash } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash } });
});
test('base view', async({ render, page, createSocket }) => {
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
......@@ -42,13 +46,13 @@ test('base view', async({ render, page, createSocket }) => {
});
test('with verified info', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => {
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await page.getByRole('button', { name: /project info/i }).click();
......@@ -60,17 +64,24 @@ test('with verified info', async({ render, page, createSocket, mockApiResponse,
});
test('bridged token', async({ render, page, createSocket, mockApiResponse, mockAssetResponse, mockEnvs }) => {
const hash = bridgedTokenA.address;
const hooksConfig = {
router: {
query: { hash, tab: 'token_transfers' },
},
};
await mockEnvs(ENVS_MAP.bridgedTokens);
await mockApiResponse('token', bridgedTokenA, { pathParams: { hash: '1' } });
await mockApiResponse('address', contract, { pathParams: { hash: '1' } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash: '1' } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash: '1' } });
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
await mockApiResponse('token', bridgedTokenA, { pathParams: { hash } });
await mockApiResponse('address', contract, { pathParams: { hash } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash } });
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash.toLowerCase() }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
......@@ -85,7 +96,7 @@ test.describe('mobile', () => {
test('base view', async({ render, page, createSocket }) => {
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
......@@ -95,12 +106,12 @@ test.describe('mobile', () => {
});
test('with verified info', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => {
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
......
import { Box, Flex, Tooltip } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React, { useEffect } from 'react';
......@@ -10,7 +10,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata';
......@@ -24,25 +23,17 @@ import { getTokenHoldersStub } from 'stubs/token';
import { generateListStub } from 'stubs/utils';
import AddressContract from 'ui/address/AddressContract';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags';
import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenInventory from 'ui/token/TokenInventory';
import TokenPageTitle from 'ui/token/TokenPageTitle';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenVerifiedInfo from 'ui/token/TokenVerifiedInfo';
import useTokenQuery from 'ui/token/useTokenQuery';
export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
......@@ -58,8 +49,6 @@ const TokenPageContent = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const scrollRef = React.useRef<HTMLDivElement>(null);
const hashString = getQueryParamString(router.query.hash);
......@@ -68,13 +57,7 @@ const TokenPageContent = () => {
const queryClient = useQueryClient();
const tokenQuery = useApiQuery('token', {
pathParams: { hash: hashString },
queryOptions: {
enabled: Boolean(router.query.hash),
placeholderData: tokenStubs.TOKEN_INFO_ERC_20,
},
});
const tokenQuery = useTokenQuery(hashString);
const addressQuery = useApiQuery('address', {
pathParams: { hash: hashString },
......@@ -121,8 +104,8 @@ const TokenPageContent = () => {
});
useEffect(() => {
if (tokenQuery.data && !tokenQuery.isPlaceholderData) {
metadata.update({ pathname: '/token/[hash]', query: { hash: tokenQuery.data.address } }, { symbol: tokenQuery.data.symbol ?? '' });
if (tokenQuery.data && !tokenQuery.isPlaceholderData && !config.meta.seo.enhancedDataEnabled) {
metadata.update({ pathname: '/token/[hash]', query: { hash: tokenQuery.data.address } }, tokenQuery.data);
}
}, [ tokenQuery.data, tokenQuery.isPlaceholderData ]);
......@@ -174,21 +157,25 @@ const TokenPageContent = () => {
},
});
const verifiedInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash: hashString, chainId: config.chain.id },
queryOptions: { enabled: Boolean(tokenQuery.data) && config.features.verifiedTokens.isEnabled },
});
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData;
const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData);
const tabs: Array<RoutedTab> = [
hasInventoryTab ? {
id: 'inventory',
title: 'Inventory',
component: <TokenInventory inventoryQuery={ inventoryQuery } tokenQuery={ tokenQuery } ownerFilter={ ownerFilter }/>,
component: <TokenInventory inventoryQuery={ inventoryQuery } tokenQuery={ tokenQuery } ownerFilter={ ownerFilter } shouldRender={ !isLoading }/>,
} : undefined,
{ id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data }/> },
{ id: 'holders', title: 'Holders', component: <TokenHolders token={ tokenQuery.data } holdersQuery={ holdersQuery }/> },
{
id: 'token_transfers',
title: 'Token transfers',
component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data } shouldRender={ !isLoading }/>,
},
{
id: 'holders',
title: 'Holders',
component: <TokenHolders token={ tokenQuery.data } holdersQuery={ holdersQuery } shouldRender={ !isLoading }/>,
},
addressQuery.data?.is_contract ? {
id: 'contract',
title: () => {
......@@ -203,7 +190,7 @@ const TokenPageContent = () => {
return 'Contract';
},
component: <AddressContract tabs={ contractTabs.tabs } isLoading={ contractTabs.isLoading }/>,
component: <AddressContract tabs={ contractTabs.tabs } isLoading={ contractTabs.isLoading } shouldRender={ !isLoading }/>,
subTabs: contractTabs.tabs.map(tab => tab.id),
} : undefined,
].filter(Boolean);
......@@ -224,8 +211,6 @@ const TokenPageContent = () => {
pagination = inventoryQuery.pagination;
}
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
const tabListProps = React.useCallback(({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => {
if (isMobile) {
return { mt: 8 };
......@@ -239,68 +224,6 @@ const TokenPageContent = () => {
};
}, [ isMobile ]);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to tokens list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const titleContentAfter = (
<>
{ verifiedInfoQuery.data?.tokenAddress && (
<Tooltip label={ `Information on this token has been verified by ${ config.chain.name }` }>
<Box boxSize={ 6 }>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
</Box>
</Tooltip>
) }
<EntityTags
data={ addressQuery.data }
isLoading={ tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{ label: 'bridged', display_name: 'Bridged', colorScheme: 'blue', variant: 'solid' } :
undefined,
] }
tagsAfter={
verifiedInfoQuery.data?.projectSector ?
[ { label: verifiedInfoQuery.data.projectSector, display_name: verifiedInfoQuery.data.projectSector } ] :
undefined
}
flexGrow={ 1 }
/>
</>
);
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData;
const titleSecondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<AddressEntity
address={{ ...addressQuery.data, name: '' }}
isLoading={ isLoading }
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
/>
{ !isLoading && tokenQuery.data && <AddressAddToWallet token={ tokenQuery.data } variant="button"/> }
<AddressQrCode address={ addressQuery.data } isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading }/>
<Flex ml={{ base: 0, lg: 'auto' }} columnGap={ 2 } flexGrow={{ base: 1, lg: 0 }}>
<TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery }/>
<NetworkExplorers type="token" pathParam={ hashString } ml={{ base: 'auto', lg: 0 }}/>
</Flex>
</Flex>
);
const tabsRightSlot = React.useMemo(() => {
if (isMobile) {
return null;
......@@ -323,37 +246,22 @@ const TokenPageContent = () => {
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title={ `${ tokenQuery.data?.name || 'Unnamed token' }${ tokenSymbolText }` }
isLoading={ isLoading }
backLink={ backLink }
beforeTitle={ tokenQuery.data ? (
<TokenEntity.Icon
token={ tokenQuery.data }
isLoading={ isLoading }
iconSize="lg"
/>
) : null }
contentAfter={ titleContentAfter }
secondRow={ titleSecondRow }
/>
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery }/>
<TokenDetails tokenQuery={ tokenQuery }/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box>
{ isLoading ?
<TabsSkeleton tabs={ tabs }/> :
(
<RoutedTabs
tabs={ tabs }
tabListProps={ tabListProps }
rightSlot={ tabsRightSlot }
rightSlotProps={ TABS_RIGHT_SLOT_PROPS }
stickyEnabled={ !isMobile }
/>
) }
<RoutedTabs
tabs={ tabs }
tabListProps={ tabListProps }
rightSlot={ tabsRightSlot }
rightSlotProps={ TABS_RIGHT_SLOT_PROPS }
stickyEnabled={ !isMobile }
isLoading={ isLoading }
/>
</>
);
};
......
......@@ -11,6 +11,7 @@ import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getCurrencyValue from 'lib/getCurrencyValue';
import useIsMounted from 'lib/hooks/useIsMounted';
import { TOKEN_COUNTERS } from 'stubs/token';
import type { TokenTabs } from 'ui/pages/Token';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -25,6 +26,7 @@ interface Props {
const TokenDetails = ({ tokenQuery }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const hash = router.query.hash?.toString();
const tokenCountersQuery = useApiQuery('token_counters', {
......@@ -66,6 +68,10 @@ const TokenDetails = ({ tokenQuery }: Props) => {
throwOnResourceLoadError(tokenQuery);
if (!isMounted) {
return null;
}
const {
exchange_rate: exchangeRate,
total_supply: totalSupply,
......
......@@ -4,6 +4,7 @@ import React from 'react';
import type { TokenInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
......@@ -17,11 +18,17 @@ import TokenHoldersTable from './TokenHoldersTable';
type Props = {
token?: TokenInfo;
holdersQuery: QueryWithPagesResult<'token_holders'>;
shouldRender?: boolean;
}
const TokenHoldersContent = ({ holdersQuery, token }: Props) => {
const TokenHoldersContent = ({ holdersQuery, token, shouldRender = true }: Props) => {
const isMobile = useIsMobile();
const isMounted = useIsMounted();
if (!isMounted || !shouldRender) {
return null;
}
if (holdersQuery.isError) {
return <DataFetchAlert/>;
}
......
......@@ -7,6 +7,7 @@ import type { TokenInfo } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
......@@ -20,15 +21,21 @@ type Props = {
inventoryQuery: QueryWithPagesResult<'token_inventory'>;
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
ownerFilter?: string;
shouldRender?: boolean;
}
const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => {
const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter, shouldRender = true }: Props) => {
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const resetOwnerFilter = React.useCallback(() => {
inventoryQuery.onFilterChange({});
}, [ inventoryQuery ]);
if (!isMounted || !shouldRender) {
return null;
}
const isActionBarHidden = !ownerFilter && !inventoryQuery.data?.items.length;
const ownerFilterComponent = ownerFilter && (
......
import { Box, Flex, Tooltip } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { Address } from 'types/api/address';
import type { TokenInfo } from 'types/api/token';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags';
import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import TokenVerifiedInfo from './TokenVerifiedInfo';
interface Props {
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
addressQuery: UseQueryResult<Address, ResourceError<unknown>>;
}
const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
const appProps = useAppContext();
const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : '';
const verifiedInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash: addressHash, chainId: config.chain.id },
queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled },
});
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData || (
config.features.verifiedTokens.isEnabled ? verifiedInfoQuery.isPending : false
);
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to tokens list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const contentAfter = (
<>
{ verifiedInfoQuery.data?.tokenAddress && (
<Tooltip label={ `Information on this token has been verified by ${ config.chain.name }` }>
<Box boxSize={ 6 }>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
</Box>
</Tooltip>
) }
<EntityTags
data={ addressQuery.data }
isLoading={ isLoading }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{ label: 'bridged', display_name: 'Bridged', colorScheme: 'blue', variant: 'solid' } :
undefined,
] }
tagsAfter={
verifiedInfoQuery.data?.projectSector ?
[ { label: verifiedInfoQuery.data.projectSector, display_name: verifiedInfoQuery.data.projectSector } ] :
undefined
}
flexGrow={ 1 }
/>
</>
);
const secondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<AddressEntity
address={{ ...addressQuery.data, name: '' }}
isLoading={ isLoading }
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
/>
{ !isLoading && tokenQuery.data && <AddressAddToWallet token={ tokenQuery.data } variant="button"/> }
<AddressQrCode address={ addressQuery.data } isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading }/>
<Flex ml={{ base: 0, lg: 'auto' }} columnGap={ 2 } flexGrow={{ base: 1, lg: 0 }}>
<TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery }/>
<NetworkExplorers type="token" pathParam={ addressHash } ml={{ base: 'auto', lg: 0 }}/>
</Flex>
</Flex>
);
return (
<PageTitle
title={ `${ tokenQuery.data?.name || 'Unnamed token' }${ tokenSymbolText }` }
isLoading={ tokenQuery.isPlaceholderData }
backLink={ backLink }
beforeTitle={ tokenQuery.data ? (
<TokenEntity.Icon
token={ tokenQuery.data }
isLoading={ tokenQuery.isPlaceholderData }
iconSize="lg"
/>
) : null }
contentAfter={ contentAfter }
secondRow={ secondRow }
/>
);
};
export default TokenPageTitle;
......@@ -7,6 +7,7 @@ import type { TokenInfo } from 'types/api/token';
import useGradualIncrement from 'lib/hooks/useGradualIncrement';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import ActionBar from 'ui/shared/ActionBar';
......@@ -21,10 +22,12 @@ type Props = {
transfersQuery: QueryWithPagesResult<'token_transfers'> | QueryWithPagesResult<'token_instance_transfers'>;
tokenId?: string;
token?: TokenInfo;
shouldRender?: boolean;
}
const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => {
const TokenTransfer = ({ transfersQuery, tokenId, token, shouldRender = true }: Props) => {
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const router = useRouter();
const { isError, isPlaceholderData, data, pagination } = transfersQuery;
......@@ -55,8 +58,11 @@ const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => {
handler: handleNewTransfersMessage,
});
const content = data?.items ? (
if (!isMounted || !shouldRender) {
return null;
}
const content = data?.items ? (
<>
<Box display={{ base: 'none', lg: 'block' }}>
<TokenTransferTable
......
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import * as tokenStubs from 'stubs/token';
export default function useTokenQuery(hash: string) {
const { apiData } = useAppContext<'/token/[hash]'>();
return useApiQuery('token', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: tokenStubs.TOKEN_INFO_ERC_20,
initialData: apiData || undefined,
},
});
}
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