Skip to content

Commit 46910c3

Browse files
committed
token product schema
1 parent ba98fb5 commit 46910c3

File tree

8 files changed

+147
-15
lines changed

8 files changed

+147
-15
lines changed

lib/metadata/__snapshots__/generate.test.ts.snap

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ exports[`generates correct metadata for: dynamic route 1`] = `
44
{
55
"canonical": undefined,
66
"description": "View transaction 0x12345 on Blockscout (Blockscout) Explorer",
7+
"jsonLd": undefined,
78
"opengraph": {
89
"description": "",
910
"imageUrl": "",
@@ -17,6 +18,19 @@ exports[`generates correct metadata for: dynamic route with API data 1`] = `
1718
{
1819
"canonical": undefined,
1920
"description": "0x12345, balances and analytics on the Blockscout (Blockscout) Explorer",
21+
"jsonLd": {
22+
"@context": "https://schema.org",
23+
"@type": "Product",
24+
"description": "USDT is a stablecoin",
25+
"image": "https://example.com/usdt.png",
26+
"name": "USDT",
27+
"offers": {
28+
"@type": "Offer",
29+
"price": "1.00",
30+
"priceCurrency": "USD",
31+
},
32+
"url": "http://localhost:3000/token/0x12345",
33+
},
2034
"opengraph": {
2135
"description": "",
2236
"imageUrl": "",
@@ -30,6 +44,7 @@ exports[`generates correct metadata for: static route 1`] = `
3044
{
3145
"canonical": "http://localhost:3000/txs",
3246
"description": "Open-source block explorer by Blockscout. Search transactions, verify smart contracts, analyze addresses, and track network activity. Complete blockchain data and APIs for the Blockscout (Blockscout) Explorer network.",
47+
"jsonLd": undefined,
3348
"opengraph": {
3449
"description": "",
3550
"imageUrl": "http://localhost:3000/static/og_image.png",

lib/metadata/generate.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@ const TEST_CASES = [
3333
pathname: '/token/[hash]',
3434
query: { hash: '0x12345' },
3535
},
36-
apiData: { symbol_or_name: 'USDT' },
36+
apiData: {
37+
symbol_or_name: 'USDT',
38+
description: 'USDT is a stablecoin',
39+
icon_url: 'https://example.com/usdt.png',
40+
exchange_rate: '1.00',
41+
name: 'USDT',
42+
symbol: 'USDT',
43+
},
3744
} as TestCase<'/token/[hash]'>,
3845
];
3946

lib/metadata/generate.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import getNetworkTitle from 'lib/networks/getNetworkTitle';
88
import { currencyUnits } from 'lib/units';
99

1010
import compileValue from './compileValue';
11+
import generateProductSchema from './generateProductSchema';
1112
import getCanonicalUrl from './getCanonicalUrl';
1213
import getPageOgType from './getPageOgType';
1314
import * as templates from './templates';
@@ -29,6 +30,7 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout
2930
const description = compileValue(templates.description.make(route.pathname, Boolean(apiData)), params);
3031

3132
const pageOgType = getPageOgType(route.pathname);
33+
const jsonLd = generateProductSchema(route, apiData);
3234

3335
return {
3436
title: title,
@@ -39,5 +41,6 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout
3941
imageUrl: pageOgType !== 'Regular page' ? config.meta.og.imageUrl : '',
4042
},
4143
canonical: getCanonicalUrl(route.pathname),
44+
jsonLd,
4245
};
4346
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { ProductSchema, ApiData } from './types';
2+
import type { RouteParams } from 'nextjs/types';
3+
4+
import type { Route } from 'nextjs-routes';
5+
6+
import config from 'configs/app';
7+
8+
/**
9+
* Generates Product schema (JSON-LD) for token pages
10+
* Returns undefined for non-token pages or when data is not available
11+
*/
12+
export default function generateProductSchema<Pathname extends Route['pathname']>(
13+
route: RouteParams<Pathname>,
14+
apiData: ApiData<Pathname>,
15+
): ProductSchema | undefined {
16+
// Only generate for token pages
17+
if (route.pathname !== '/token/[hash]') {
18+
return undefined;
19+
}
20+
21+
// Only generate if we have token data
22+
if (!apiData || typeof apiData !== 'object') {
23+
return undefined;
24+
}
25+
26+
const tokenData = apiData as ApiData<'/token/[hash]'>;
27+
if (!tokenData) {
28+
return undefined;
29+
}
30+
31+
const hash = typeof route.query?.hash === 'string' ? route.query.hash : undefined;
32+
if (!hash) {
33+
return undefined;
34+
}
35+
36+
const baseUrl = config.app.baseUrl;
37+
const tokenUrl = `${ baseUrl }/token/${ hash }`;
38+
39+
const schema: ProductSchema = {
40+
'@context': 'https://schema.org',
41+
'@type': 'Product',
42+
name: tokenData.name || tokenData.symbol || undefined,
43+
description: tokenData.description || undefined,
44+
image: tokenData.icon_url || undefined,
45+
url: tokenUrl,
46+
};
47+
48+
// Only include offers if we have a valid price
49+
// Schema.org requires valid price data when including an Offer
50+
if (tokenData.exchange_rate) {
51+
schema.offers = {
52+
'@type': 'Offer',
53+
price: tokenData.exchange_rate,
54+
priceCurrency: 'USD',
55+
};
56+
}
57+
58+
return schema;
59+
}

lib/metadata/types.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,27 @@ import type { Route } from 'nextjs-routes';
77
export type ApiData<Pathname extends Route['pathname']> =
88
(
99
Pathname extends '/address/[hash]' ? { domain_name: string } :
10-
Pathname extends '/token/[hash]' ? TokenInfo & { symbol_or_name: string } :
10+
Pathname extends '/token/[hash]' ? TokenInfo & { symbol_or_name: string; description?: string } :
1111
Pathname extends '/token/[hash]/instance/[id]' ? { symbol_or_name: string } :
1212
Pathname extends '/apps/[id]' ? { app_name: string } :
1313
Pathname extends '/stats/[id]' ? LineChart['info'] :
1414
never
1515
) | null;
1616

17+
export interface ProductSchema {
18+
'@context': string;
19+
'@type': 'Product';
20+
name?: string;
21+
description?: string;
22+
image?: string;
23+
url?: string;
24+
offers?: {
25+
'@type': 'Offer';
26+
price?: string;
27+
priceCurrency?: string;
28+
};
29+
}
30+
1731
export interface Metadata {
1832
title: string;
1933
description: string;
@@ -23,4 +37,5 @@ export interface Metadata {
2337
imageUrl?: string;
2438
};
2539
canonical: string | undefined;
40+
jsonLd?: ProductSchema;
2641
}

lib/metadata/update.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,31 @@ import type { Route } from 'nextjs-routes';
55

66
import generate from './generate';
77

8+
const JSON_LD_SCRIPT_ID = 'blockscout-product-schema';
9+
810
export default function update<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname>) {
9-
const { title, description } = generate(route, apiData);
11+
const { title, description, jsonLd } = generate(route, apiData);
1012

1113
window.document.title = title;
1214
window.document.querySelector('meta[name="description"]')?.setAttribute('content', description);
15+
16+
// Update or create JSON-LD script tag for Product schema
17+
if (jsonLd) {
18+
let scriptElement = window.document.getElementById(JSON_LD_SCRIPT_ID) as HTMLScriptElement | null;
19+
20+
if (!scriptElement) {
21+
scriptElement = window.document.createElement('script');
22+
scriptElement.id = JSON_LD_SCRIPT_ID;
23+
scriptElement.type = 'application/ld+json';
24+
window.document.head.appendChild(scriptElement);
25+
}
26+
27+
scriptElement.textContent = JSON.stringify(jsonLd);
28+
} else {
29+
// Remove JSON-LD script if it exists but schema is not needed
30+
const existingScript = window.document.getElementById(JSON_LD_SCRIPT_ID);
31+
if (existingScript) {
32+
existingScript.remove();
33+
}
34+
}
1335
}

ui/pages/Token.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,23 @@ const TokenPageContent = () => {
108108
handler: handleTotalSupplyMessage,
109109
});
110110

111+
const verifiedInfoQuery = useApiQuery('contractInfo:token_verified_info', {
112+
pathParams: { hash: tokenQuery.data?.address_hash, chainId: config.chain.id },
113+
queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled },
114+
});
115+
111116
useEffect(() => {
112-
if (tokenQuery.data && !tokenQuery.isPlaceholderData && !config.meta.seo.enhancedDataEnabled) {
113-
const apiData = { ...tokenQuery.data, symbol_or_name: tokenQuery.data.symbol ?? tokenQuery.data.name ?? '' };
117+
// even if config.meta.seo.enhancedDataEnabled is enabled, we don't fetch contract info for the project description
118+
// so we need to update the metadata anyway.
119+
if (tokenQuery.data && !tokenQuery.isPlaceholderData && !verifiedInfoQuery.isPlaceholderData) {
120+
const apiData = {
121+
...tokenQuery.data,
122+
symbol_or_name: tokenQuery.data.symbol ?? tokenQuery.data.name ?? '',
123+
description: verifiedInfoQuery.data?.projectDescription,
124+
};
114125
metadata.update({ pathname: '/token/[hash]', query: { hash: tokenQuery.data.address_hash } }, apiData);
115126
}
116-
}, [ tokenQuery.data, tokenQuery.isPlaceholderData ]);
127+
}, [ tokenQuery.data, tokenQuery.isPlaceholderData, verifiedInfoQuery.isPlaceholderData, verifiedInfoQuery.data?.projectDescription ]);
117128

118129
const hasData = (tokenQuery.data && !tokenQuery.isPlaceholderData) && (addressQuery.data && !addressQuery.isPlaceholderData);
119130
const hasInventoryTab = tokenQuery.data?.type && NFT_TOKEN_TYPE_IDS.includes(tokenQuery.data.type);
@@ -266,7 +277,12 @@ const TokenPageContent = () => {
266277
<>
267278
<TextAd mb={ 6 }/>
268279

269-
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery } hash={ hashString }/>
280+
<TokenPageTitle
281+
tokenQuery={ tokenQuery }
282+
addressQuery={ addressQuery }
283+
verifiedInfoQuery={ verifiedInfoQuery }
284+
hash={ hashString }
285+
/>
270286

271287
<TokenDetails tokenQuery={ tokenQuery }/>
272288

ui/token/TokenPageTitle.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ import type { UseQueryResult } from '@tanstack/react-query';
33
import React from 'react';
44

55
import type { Address } from 'types/api/address';
6-
import type { TokenInfo } from 'types/api/token';
6+
import type { TokenInfo, TokenVerifiedInfo as TTokenVerifiedInfo } from 'types/api/token';
77
import type { EntityTag } from 'ui/shared/EntityTags/types';
88

99
import config from 'configs/app';
1010
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
1111
import type { ResourceError } from 'lib/api/resources';
12-
import useApiQuery from 'lib/api/useApiQuery';
1312
import { useMultichainContext } from 'lib/contexts/multichain';
1413
import { getTokenTypeName } from 'lib/token/tokenTypes';
1514
import { Tooltip } from 'toolkit/chakra/tooltip';
@@ -33,18 +32,14 @@ const PREDEFINED_TAG_PRIORITY = 100;
3332
interface Props {
3433
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
3534
addressQuery: UseQueryResult<Address, ResourceError<unknown>>;
35+
verifiedInfoQuery: UseQueryResult<TTokenVerifiedInfo, ResourceError<unknown>>;
3636
hash: string;
3737
}
3838

39-
const TokenPageTitle = ({ tokenQuery, addressQuery, hash }: Props) => {
39+
const TokenPageTitle = ({ tokenQuery, addressQuery, verifiedInfoQuery, hash }: Props) => {
4040
const multichainContext = useMultichainContext();
4141
const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address_hash || '') : '';
4242

43-
const verifiedInfoQuery = useApiQuery('contractInfo:token_verified_info', {
44-
pathParams: { hash: addressHash, chainId: config.chain.id },
45-
queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled },
46-
});
47-
4843
const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
4944
const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
5045

0 commit comments

Comments
 (0)