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({ ...@@ -10,6 +10,9 @@ const meta = Object.freeze({
imageUrl: app.baseUrl + (getExternalAssetFilePath('NEXT_PUBLIC_OG_IMAGE_URL') || defaultImageUrl), imageUrl: app.baseUrl + (getExternalAssetFilePath('NEXT_PUBLIC_OG_IMAGE_URL') || defaultImageUrl),
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED') === 'true', enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED') === 'true',
}, },
seo: {
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED') === 'true',
},
}); });
export default meta; export default meta;
...@@ -55,3 +55,5 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true ...@@ -55,3 +55,5 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
#meta #meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true 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 ...@@ -604,6 +604,7 @@ const schema = yup
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest), NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: yup.boolean(), 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_SAFE_TX_SERVICE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(), NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(), NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
......
...@@ -42,6 +42,7 @@ NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation ...@@ -42,6 +42,7 @@ NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_OG_DESCRIPTION='Hello world!' NEXT_PUBLIC_OG_DESCRIPTION='Hello world!'
NEXT_PUBLIC_OG_IMAGE_URL=https://example.com/image.png NEXT_PUBLIC_OG_IMAGE_URL=https://example.com/image.png
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true 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_OTHER_LINKS=[{'url':'https://blockscout.com','text':'Blockscout'}]
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE=true NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE=true
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global 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 ...@@ -172,7 +172,7 @@ By default, the app has generic favicon. You can override this behavior by provi
### Meta ### Meta
Settings for meta tags and OG tags Settings for meta tags, OG tags and SEO
| Variable | Type| Description | Compulsoriness | Default value | Example value | | Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
...@@ -180,6 +180,7 @@ Settings for meta tags and OG tags ...@@ -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_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_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_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; &nbsp;
......
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import type { Route } from 'nextjs-routes';
import type { Props as PageProps } from 'nextjs/getServerSideProps'; import type { Props as PageProps } from 'nextjs/getServerSideProps';
type Props = { type Props = {
...@@ -23,6 +24,6 @@ export function AppContextProvider({ children, pageProps }: Props) { ...@@ -23,6 +24,6 @@ export function AppContextProvider({ children, pageProps }: Props) {
); );
} }
export function useAppContext() { export function useAppContext<Pathname extends Route['pathname'] = never>() {
return useContext(AppContext); return useContext<PageProps<Pathname>>(AppContext);
} }
import type { TokenInfo } from 'types/api/token';
import type { Route } from 'nextjs-routes'; import type { Route } from 'nextjs-routes';
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
export type ApiData<Pathname extends Route['pathname']> = export type ApiData<Pathname extends Route['pathname']> =
( (
Pathname extends '/address/[hash]' ? { domain_name: string } : 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 '/token/[hash]/instance/[id]' ? { symbol: string } :
Pathname extends '/apps/[id]' ? { app_name: string } : Pathname extends '/apps/[id]' ? { app_name: string } :
never never
......
import type { GetServerSideProps, NextPage } from 'next'; import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react'; import React from 'react';
import type { Route } from 'nextjs-routes'; import type { Route } from 'nextjs-routes';
...@@ -11,8 +10,7 @@ import fetchApi from 'nextjs/utils/fetchApi'; ...@@ -11,8 +10,7 @@ import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app'; import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import Token from 'ui/pages/Token';
const Token = dynamic(() => import('ui/pages/Token'), { ssr: false });
const pathname: Route['pathname'] = '/token/[hash]'; const pathname: Route['pathname'] = '/token/[hash]';
...@@ -29,19 +27,18 @@ export default Page; ...@@ -29,19 +27,18 @@ export default Page;
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => { export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx); const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) { if ('props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req); if (
config.meta.seo.enhancedDataEnabled ||
if (botInfo?.type === 'social_preview') { (config.meta.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview')
) {
const tokenData = await fetchApi({ const tokenData = await fetchApi({
resource: 'token', resource: 'token',
pathParams: { hash: getQueryParamString(ctx.query.hash) }, pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000, timeout: 500,
}); });
(await baseResponse.props).apiData = tokenData && tokenData.symbol ? { (await baseResponse.props).apiData = tokenData ?? null;
symbol: tokenData.symbol,
} : null;
} }
} }
......
import React from 'react'; import React from 'react';
import config from 'configs/app';
import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses'; import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses';
import { token as contract } from 'mocks/address/address'; import { token as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters, bridgedTokenA } from 'mocks/tokens/tokenInfo'; import { tokenInfo, tokenCounters, bridgedTokenA } from 'mocks/tokens/tokenInfo';
...@@ -10,9 +11,12 @@ import * as configs from 'playwright/utils/configs'; ...@@ -10,9 +11,12 @@ import * as configs from 'playwright/utils/configs';
import Token from './Token'; import Token from './Token';
const hash = tokenInfo.address;
const chainId = config.chain.id;
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { hash: '1', tab: 'token_transfers' }, query: { hash, tab: 'token_transfers' },
isReady: true, isReady: true,
}, },
}; };
...@@ -22,17 +26,17 @@ const hooksConfig = { ...@@ -22,17 +26,17 @@ const hooksConfig = {
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
test.beforeEach(async({ mockApiResponse }) => { test.beforeEach(async({ mockApiResponse }) => {
await mockApiResponse('token', tokenInfo, { pathParams: { hash: '1' } }); await mockApiResponse('token', tokenInfo, { pathParams: { hash } });
await mockApiResponse('address', contract, { pathParams: { hash: '1' } }); await mockApiResponse('address', contract, { pathParams: { hash } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash: '1' } }); await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash: '1' } }); await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash } });
}); });
test('base view', async({ render, page, createSocket }) => { test('base view', async({ render, page, createSocket }) => {
const component = await render(<Token/>, { hooksConfig }, { withSocket: true }); const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket(); 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 }); socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
...@@ -42,13 +46,13 @@ test('base view', async({ render, page, createSocket }) => { ...@@ -42,13 +46,13 @@ test('base view', async({ render, page, createSocket }) => {
}); });
test('with verified info', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => { 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'); await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true }); const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket(); 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 }); socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await page.getByRole('button', { name: /project info/i }).click(); await page.getByRole('button', { name: /project info/i }).click();
...@@ -60,17 +64,24 @@ test('with verified info', async({ render, page, createSocket, mockApiResponse, ...@@ -60,17 +64,24 @@ test('with verified info', async({ render, page, createSocket, mockApiResponse,
}); });
test('bridged token', async({ render, page, createSocket, mockApiResponse, mockAssetResponse, mockEnvs }) => { 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 mockEnvs(ENVS_MAP.bridgedTokens);
await mockApiResponse('token', bridgedTokenA, { pathParams: { hash: '1' } }); await mockApiResponse('token', bridgedTokenA, { pathParams: { hash } });
await mockApiResponse('address', contract, { pathParams: { hash: '1' } }); await mockApiResponse('address', contract, { pathParams: { hash } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash: '1' } }); await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash: '1' } }); await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash } });
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'); await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true }); const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket(); 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 }); socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
...@@ -85,7 +96,7 @@ test.describe('mobile', () => { ...@@ -85,7 +96,7 @@ test.describe('mobile', () => {
test('base view', async({ render, page, createSocket }) => { test('base view', async({ render, page, createSocket }) => {
const component = await render(<Token/>, { hooksConfig }, { withSocket: true }); const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket(); 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 }); socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
...@@ -95,12 +106,12 @@ test.describe('mobile', () => { ...@@ -95,12 +106,12 @@ test.describe('mobile', () => {
}); });
test('with verified info', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => { 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'); await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true }); const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket(); 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 }); socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({ 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 { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
...@@ -10,7 +10,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types'; ...@@ -10,7 +10,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import useContractTabs from 'lib/hooks/useContractTabs'; import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata'; import * as metadata from 'lib/metadata';
...@@ -24,25 +23,17 @@ import { getTokenHoldersStub } from 'stubs/token'; ...@@ -24,25 +23,17 @@ import { getTokenHoldersStub } from 'stubs/token';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import AddressContract from 'ui/address/AddressContract'; import AddressContract from 'ui/address/AddressContract';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink'; 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 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 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 Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import TokenDetails from 'ui/token/TokenDetails'; import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders'; import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenInventory from 'ui/token/TokenInventory'; import TokenInventory from 'ui/token/TokenInventory';
import TokenPageTitle from 'ui/token/TokenPageTitle';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer'; 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'; export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
...@@ -58,8 +49,6 @@ const TokenPageContent = () => { ...@@ -58,8 +49,6 @@ const TokenPageContent = () => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const appProps = useAppContext();
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
const hashString = getQueryParamString(router.query.hash); const hashString = getQueryParamString(router.query.hash);
...@@ -68,13 +57,7 @@ const TokenPageContent = () => { ...@@ -68,13 +57,7 @@ const TokenPageContent = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const tokenQuery = useApiQuery('token', { const tokenQuery = useTokenQuery(hashString);
pathParams: { hash: hashString },
queryOptions: {
enabled: Boolean(router.query.hash),
placeholderData: tokenStubs.TOKEN_INFO_ERC_20,
},
});
const addressQuery = useApiQuery('address', { const addressQuery = useApiQuery('address', {
pathParams: { hash: hashString }, pathParams: { hash: hashString },
...@@ -121,8 +104,8 @@ const TokenPageContent = () => { ...@@ -121,8 +104,8 @@ const TokenPageContent = () => {
}); });
useEffect(() => { useEffect(() => {
if (tokenQuery.data && !tokenQuery.isPlaceholderData) { if (tokenQuery.data && !tokenQuery.isPlaceholderData && !config.meta.seo.enhancedDataEnabled) {
metadata.update({ pathname: '/token/[hash]', query: { hash: tokenQuery.data.address } }, { symbol: tokenQuery.data.symbol ?? '' }); metadata.update({ pathname: '/token/[hash]', query: { hash: tokenQuery.data.address } }, tokenQuery.data);
} }
}, [ tokenQuery.data, tokenQuery.isPlaceholderData ]); }, [ tokenQuery.data, tokenQuery.isPlaceholderData ]);
...@@ -174,21 +157,25 @@ const TokenPageContent = () => { ...@@ -174,21 +157,25 @@ const TokenPageContent = () => {
}, },
}); });
const verifiedInfoQuery = useApiQuery('token_verified_info', { const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData;
pathParams: { hash: hashString, chainId: config.chain.id },
queryOptions: { enabled: Boolean(tokenQuery.data) && config.features.verifiedTokens.isEnabled },
});
const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData); const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData);
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
hasInventoryTab ? { hasInventoryTab ? {
id: 'inventory', id: 'inventory',
title: 'Inventory', title: 'Inventory',
component: <TokenInventory inventoryQuery={ inventoryQuery } tokenQuery={ tokenQuery } ownerFilter={ ownerFilter }/>, component: <TokenInventory inventoryQuery={ inventoryQuery } tokenQuery={ tokenQuery } ownerFilter={ ownerFilter } shouldRender={ !isLoading }/>,
} : undefined, } : 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 ? { addressQuery.data?.is_contract ? {
id: 'contract', id: 'contract',
title: () => { title: () => {
...@@ -203,7 +190,7 @@ const TokenPageContent = () => { ...@@ -203,7 +190,7 @@ const TokenPageContent = () => {
return 'Contract'; 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), subTabs: contractTabs.tabs.map(tab => tab.id),
} : undefined, } : undefined,
].filter(Boolean); ].filter(Boolean);
...@@ -224,8 +211,6 @@ const TokenPageContent = () => { ...@@ -224,8 +211,6 @@ const TokenPageContent = () => {
pagination = inventoryQuery.pagination; pagination = inventoryQuery.pagination;
} }
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
const tabListProps = React.useCallback(({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => { const tabListProps = React.useCallback(({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => {
if (isMobile) { if (isMobile) {
return { mt: 8 }; return { mt: 8 };
...@@ -239,68 +224,6 @@ const TokenPageContent = () => { ...@@ -239,68 +224,6 @@ const TokenPageContent = () => {
}; };
}, [ isMobile ]); }, [ 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(() => { const tabsRightSlot = React.useMemo(() => {
if (isMobile) { if (isMobile) {
return null; return null;
...@@ -323,37 +246,22 @@ const TokenPageContent = () => { ...@@ -323,37 +246,22 @@ const TokenPageContent = () => {
return ( return (
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<PageTitle
title={ `${ tokenQuery.data?.name || 'Unnamed token' }${ tokenSymbolText }` } <TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery }/>
isLoading={ isLoading }
backLink={ backLink }
beforeTitle={ tokenQuery.data ? (
<TokenEntity.Icon
token={ tokenQuery.data }
isLoading={ isLoading }
iconSize="lg"
/>
) : null }
contentAfter={ titleContentAfter }
secondRow={ titleSecondRow }
/>
<TokenDetails tokenQuery={ tokenQuery }/> <TokenDetails tokenQuery={ tokenQuery }/>
{ /* should stay before tabs to scroll up with pagination */ } { /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box> <Box ref={ scrollRef }></Box>
{ isLoading ?
<TabsSkeleton tabs={ tabs }/> :
(
<RoutedTabs <RoutedTabs
tabs={ tabs } tabs={ tabs }
tabListProps={ tabListProps } tabListProps={ tabListProps }
rightSlot={ tabsRightSlot } rightSlot={ tabsRightSlot }
rightSlotProps={ TABS_RIGHT_SLOT_PROPS } rightSlotProps={ TABS_RIGHT_SLOT_PROPS }
stickyEnabled={ !isMobile } stickyEnabled={ !isMobile }
isLoading={ isLoading }
/> />
) }
</> </>
); );
}; };
......
...@@ -11,6 +11,7 @@ import type { ResourceError } from 'lib/api/resources'; ...@@ -11,6 +11,7 @@ import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import useIsMounted from 'lib/hooks/useIsMounted';
import { TOKEN_COUNTERS } from 'stubs/token'; import { TOKEN_COUNTERS } from 'stubs/token';
import type { TokenTabs } from 'ui/pages/Token'; import type { TokenTabs } from 'ui/pages/Token';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
...@@ -25,6 +26,7 @@ interface Props { ...@@ -25,6 +26,7 @@ interface Props {
const TokenDetails = ({ tokenQuery }: Props) => { const TokenDetails = ({ tokenQuery }: Props) => {
const router = useRouter(); const router = useRouter();
const isMounted = useIsMounted();
const hash = router.query.hash?.toString(); const hash = router.query.hash?.toString();
const tokenCountersQuery = useApiQuery('token_counters', { const tokenCountersQuery = useApiQuery('token_counters', {
...@@ -66,6 +68,10 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -66,6 +68,10 @@ const TokenDetails = ({ tokenQuery }: Props) => {
throwOnResourceLoadError(tokenQuery); throwOnResourceLoadError(tokenQuery);
if (!isMounted) {
return null;
}
const { const {
exchange_rate: exchangeRate, exchange_rate: exchangeRate,
total_supply: totalSupply, total_supply: totalSupply,
......
...@@ -4,6 +4,7 @@ import React from 'react'; ...@@ -4,6 +4,7 @@ import React from 'react';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink'; import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
...@@ -17,11 +18,17 @@ import TokenHoldersTable from './TokenHoldersTable'; ...@@ -17,11 +18,17 @@ import TokenHoldersTable from './TokenHoldersTable';
type Props = { type Props = {
token?: TokenInfo; token?: TokenInfo;
holdersQuery: QueryWithPagesResult<'token_holders'>; holdersQuery: QueryWithPagesResult<'token_holders'>;
shouldRender?: boolean;
} }
const TokenHoldersContent = ({ holdersQuery, token }: Props) => { const TokenHoldersContent = ({ holdersQuery, token, shouldRender = true }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMounted = useIsMounted();
if (!isMounted || !shouldRender) {
return null;
}
if (holdersQuery.isError) { if (holdersQuery.isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
......
...@@ -7,6 +7,7 @@ import type { TokenInfo } from 'types/api/token'; ...@@ -7,6 +7,7 @@ import type { TokenInfo } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
...@@ -20,15 +21,21 @@ type Props = { ...@@ -20,15 +21,21 @@ type Props = {
inventoryQuery: QueryWithPagesResult<'token_inventory'>; inventoryQuery: QueryWithPagesResult<'token_inventory'>;
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>; tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
ownerFilter?: string; ownerFilter?: string;
shouldRender?: boolean;
} }
const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => { const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter, shouldRender = true }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMounted = useIsMounted();
const resetOwnerFilter = React.useCallback(() => { const resetOwnerFilter = React.useCallback(() => {
inventoryQuery.onFilterChange({}); inventoryQuery.onFilterChange({});
}, [ inventoryQuery ]); }, [ inventoryQuery ]);
if (!isMounted || !shouldRender) {
return null;
}
const isActionBarHidden = !ownerFilter && !inventoryQuery.data?.items.length; const isActionBarHidden = !ownerFilter && !inventoryQuery.data?.items.length;
const ownerFilterComponent = ownerFilter && ( 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'; ...@@ -7,6 +7,7 @@ import type { TokenInfo } from 'types/api/token';
import useGradualIncrement from 'lib/hooks/useGradualIncrement'; import useGradualIncrement from 'lib/hooks/useGradualIncrement';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage'; import useSocketMessage from 'lib/socket/useSocketMessage';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
...@@ -21,10 +22,12 @@ type Props = { ...@@ -21,10 +22,12 @@ type Props = {
transfersQuery: QueryWithPagesResult<'token_transfers'> | QueryWithPagesResult<'token_instance_transfers'>; transfersQuery: QueryWithPagesResult<'token_transfers'> | QueryWithPagesResult<'token_instance_transfers'>;
tokenId?: string; tokenId?: string;
token?: TokenInfo; token?: TokenInfo;
shouldRender?: boolean;
} }
const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => { const TokenTransfer = ({ transfersQuery, tokenId, token, shouldRender = true }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMounted = useIsMounted();
const router = useRouter(); const router = useRouter();
const { isError, isPlaceholderData, data, pagination } = transfersQuery; const { isError, isPlaceholderData, data, pagination } = transfersQuery;
...@@ -55,8 +58,11 @@ const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => { ...@@ -55,8 +58,11 @@ const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => {
handler: handleNewTransfersMessage, handler: handleNewTransfersMessage,
}); });
const content = data?.items ? ( if (!isMounted || !shouldRender) {
return null;
}
const content = data?.items ? (
<> <>
<Box display={{ base: 'none', lg: 'block' }}> <Box display={{ base: 'none', lg: 'block' }}>
<TokenTransferTable <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