Skip to content

Commit 64f5e47

Browse files
committed
[MNY-216] Update SEO metadata on chain pages, Add FAQ section (#8135)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on refactoring and enhancing the codebase for better readability and functionality. It primarily involves renaming functions, updating component styles, and implementing a new SEO fetching mechanism. ### Detailed summary - Renamed `getChainMetadata` to `getCustomChainMetadata`. - Updated styles in several components for consistency. - Added `fetchChainSeo` function for improved SEO handling. - Replaced `chainMetadata` with `customChainMetadata` in various components. - Introduced `FaqSection` component for displaying FAQs. - Adjusted metadata generation to utilize fetched SEO data. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Chain pages now show an interactive FAQ section when SEO data includes FAQs. - SEO data is fetched and cached externally to provide richer titles, descriptions, and OpenGraph/Twitter previews. - Bug Fixes / Behavior - Metadata generation now depends on external SEO (may be unavailable), affecting preview availability and link consistency. - Chain header images and gas-sponsored indicators now use the updated metadata source. - Style - More compact Supported Products cards, adjusted overview spacing/corner radius, lighter explorer/info-item typography, and refined link styling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent aa0d23e commit 64f5e47

File tree

11 files changed

+199
-50
lines changed

11 files changed

+199
-50
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import "server-only";
2+
import { unstable_cache } from "next/cache";
3+
4+
export type ChainSeo = {
5+
title: string;
6+
description: string;
7+
og: {
8+
title: string;
9+
description: string;
10+
site_name: string;
11+
url: string;
12+
};
13+
faqs: Array<{
14+
title: string;
15+
description: string;
16+
}>;
17+
chain: {
18+
chainId: number;
19+
name: string;
20+
slug: string;
21+
nativeCurrency: {
22+
name: string;
23+
symbol: string;
24+
decimals: number;
25+
};
26+
testnet: boolean;
27+
is_deprecated: boolean;
28+
};
29+
};
30+
31+
export const fetchChainSeo = unstable_cache(
32+
async (chainId: number) => {
33+
const url = new URL(
34+
`https://seo-pages-generator-5814.zeet-nftlabs.zeet.app/chain/${chainId}`,
35+
);
36+
const res = await fetch(url, {
37+
headers: {
38+
"Content-Type": "application/json",
39+
},
40+
});
41+
42+
if (!res.ok) {
43+
return undefined;
44+
}
45+
46+
return res.json() as Promise<ChainSeo>;
47+
},
48+
["chain-seo"],
49+
{ revalidate: 60 * 60 * 24 }, // 24 hours
50+
);

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/server/ChainOverviewSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function ChainOverviewSection(props: { chain: ChainMetadata }) {
1111
return (
1212
<section>
1313
<SectionTitle title="Chain Overview" />
14-
<div className="grid grid-cols-1 gap-6 rounded-lg border bg-card p-4 md:grid-cols-2 md:p-6 lg:grid-cols-3 lg:gap-8">
14+
<div className="grid grid-cols-1 gap-6 rounded-xl border bg-card p-4 md:grid-cols-2 md:p-6 lg:grid-cols-3 lg:gap-6">
1515
{/* Info */}
1616
{chain.infoURL && (
1717
<PrimaryInfoItem title="Info">

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/server/SupportedProductsSection.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { CircleCheckIcon } from "lucide-react";
21
import Link from "next/link";
32
import type { ChainMetadataWithServices } from "@/types/chain";
43
import { products } from "../../../../components/server/products";
@@ -24,15 +23,18 @@ export function SupportedProductsSection(props: {
2423
{enabledProducts.map((product) => {
2524
return (
2625
<div
27-
className="relative flex gap-3 rounded-lg border bg-card p-4 pr-8 transition-colors hover:border-active-border"
26+
className="relative rounded-xl border bg-card p-4 hover:border-active-border"
2827
key={product.id}
2928
>
30-
<CircleCheckIcon className="absolute top-4 right-4 size-5 text-success-text" />
31-
<product.icon className="mt-0.5 size-5 shrink-0" />
29+
<div className="flex mb-4">
30+
<div className="p-2 rounded-full border bg-background">
31+
<product.icon className="size-4 text-muted-foreground" />
32+
</div>
33+
</div>
3234
<div>
33-
<h3 className="mb-1.5 font-medium">
35+
<h3 className="mb-1">
3436
<Link
35-
className="before:absolute before:inset-0"
37+
className="before:absolute before:inset-0 text-base font-medium"
3638
href={product.link}
3739
rel="noopener noreferrer"
3840
target="_blank"

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/server/explorer-section.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function ExplorersSection(props: {
1717
key={explorer.url}
1818
>
1919
<ExternalLinkIcon className="absolute top-4 right-4 size-4 text-muted-foreground" />
20-
<h3 className="mb-1 font-semibold text-base capitalize">
20+
<h3 className="mb-1 font-medium text-base capitalize">
2121
{explorer.name}
2222
</h3>
2323
<Link
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"use client";
2+
import { ChevronDownIcon } from "lucide-react";
3+
import { useId, useState } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import { DynamicHeight } from "@/components/ui/DynamicHeight";
6+
import { cn } from "@/lib/utils";
7+
import type { ChainSeo } from "../../apis/chain-seo";
8+
9+
export function FaqSection(props: { faqs: ChainSeo["faqs"] }) {
10+
return (
11+
<div className="py-10">
12+
<section className="">
13+
<h2 className="text-2xl md:text-3xl font-semibold mb-4 tracking-tight">
14+
Frequently asked questions
15+
</h2>
16+
<div className="flex flex-col">
17+
{props.faqs.map((faq, faqIndex) => (
18+
<FaqItem
19+
key={faq.title}
20+
title={faq.title}
21+
description={faq.description}
22+
className={cn(faqIndex === props.faqs.length - 1 && "border-b-0")}
23+
/>
24+
))}
25+
</div>
26+
</section>
27+
</div>
28+
);
29+
}
30+
31+
function FaqItem(props: {
32+
title: string;
33+
description: string;
34+
className?: string;
35+
}) {
36+
const [isOpen, setIsOpenn] = useState(false);
37+
const contentId = useId();
38+
return (
39+
<DynamicHeight>
40+
<div className={cn("border-b border-dashed", props.className)}>
41+
<h3>
42+
<Button
43+
variant="ghost"
44+
onClick={() => setIsOpenn(!isOpen)}
45+
aria-controls={contentId}
46+
aria-expanded={isOpen}
47+
className={cn(
48+
"w-full justify-between h-auto py-5 text-base text-muted-foreground hover:bg-transparent pl-0 text-wrap text-left gap-6",
49+
isOpen && "text-foreground",
50+
)}
51+
>
52+
{props.title}
53+
<ChevronDownIcon
54+
className={cn(
55+
"size-4 shrink-0 transition-transform duration-200",
56+
isOpen && "rotate-180",
57+
)}
58+
/>
59+
</Button>
60+
</h3>
61+
62+
<p
63+
className={cn(
64+
"text-muted-foreground text-base leading-7 pb-6 max-w-4xl",
65+
!isOpen && "hidden",
66+
)}
67+
id={contentId}
68+
>
69+
{props.description}
70+
</p>
71+
</div>
72+
</DynamicHeight>
73+
);
74+
}

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/server/primary-info-item.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ export function PrimaryInfoItem(props: {
66
return (
77
<div>
88
<div className="flex items-center gap-2">
9-
<h3 className="font-medium text-base text-muted-foreground">
10-
{props.title}
11-
</h3>
9+
<h3 className="text-base text-muted-foreground">{props.title}</h3>
1210
{props.titleIcon}
1311
</div>
1412
{props.children}

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/layout.tsx

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,39 +22,51 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
2222
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
2323
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
2424
import { StarButton } from "../../components/client/star-button";
25-
import { getChain, getChainMetadata } from "../../utils";
25+
import { getChain, getCustomChainMetadata } from "../../utils";
26+
import { fetchChainSeo } from "./apis/chain-seo";
2627
import { AddChainToWallet } from "./components/client/add-chain-to-wallet";
2728
import { ChainPageView } from "./components/client/chain-pageview";
2829
import { ChainHeader } from "./components/server/chain-header";
2930

30-
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
31+
type Params = Promise<{ chain_id: string }>;
3132

3233
export async function generateMetadata(props: {
33-
params: Promise<{ chain_id: string }>;
34-
}): Promise<Metadata> {
34+
params: Params;
35+
}): Promise<Metadata | undefined> {
3536
const params = await props.params;
3637
const chain = await getChain(params.chain_id);
37-
const sanitizedChainName = chain.name.replace("Mainnet", "").trim();
38-
const title = `${sanitizedChainName}: RPC and Chain Settings`;
38+
const chainSeo = await fetchChainSeo(Number(chain.chainId)).catch(
39+
() => undefined,
40+
);
3941

40-
const description = `Use the best ${sanitizedChainName} RPC and add to your wallet. Discover the chain ID, native token, explorers, and ${
41-
chain.testnet && chain.faucets?.length ? "faucet options" : "more"
42-
}.`;
42+
if (!chainSeo) {
43+
return undefined;
44+
}
4345

4446
return {
45-
description,
47+
title: chainSeo.title,
48+
description: chainSeo.description,
49+
metadataBase: new URL("https://thirdweb.com"),
4650
openGraph: {
47-
description,
48-
title,
51+
title: chainSeo.og.title,
52+
description: chainSeo.og.description,
53+
siteName: "thirdweb",
54+
type: "website",
55+
url: "https://thirdweb.com",
56+
},
57+
twitter: {
58+
title: chainSeo.og.title,
59+
description: chainSeo.og.description,
60+
card: "summary_large_image",
61+
creator: "@thirdweb",
62+
site: "@thirdweb",
4963
},
50-
title,
5164
};
5265
}
5366

54-
// this is the dashboard layout file
5567
export default async function ChainPageLayout(props: {
5668
children: React.ReactNode;
57-
params: Promise<{ chain_id: string }>;
69+
params: Params;
5870
}) {
5971
const params = await props.params;
6072
const { children } = props;
@@ -67,8 +79,10 @@ export default async function ChainPageLayout(props: {
6779
redirect(chain.slug);
6880
}
6981

70-
const chainMetadata = await getChainMetadata(chain.chainId);
82+
const customChainMetadata = getCustomChainMetadata(chain.chainId);
83+
const chainSeo = await fetchChainSeo(chain.chainId);
7184
const client = getClientThirdwebClient(undefined);
85+
const description = customChainMetadata?.about || chainSeo?.description;
7286

7387
return (
7488
<div className="flex grow flex-col">
@@ -123,15 +137,15 @@ export default async function ChainPageLayout(props: {
123137
<ChainHeader
124138
chain={chain}
125139
client={client}
126-
headerImageUrl={chainMetadata?.headerImgUrl}
140+
headerImageUrl={customChainMetadata?.headerImgUrl}
127141
logoUrl={chain.icon?.url}
128142
/>
129143

130144
<div className="h-4 md:h-8" />
131145

132146
<div className="flex flex-col gap-3 md:gap-2">
133147
{/* Gas Sponsored badge - Mobile */}
134-
{chainMetadata?.gasSponsored && (
148+
{customChainMetadata?.gasSponsored && (
135149
<div className="flex md:hidden">
136150
<GasSponsoredBadge />
137151
</div>
@@ -153,17 +167,17 @@ export default async function ChainPageLayout(props: {
153167
)}
154168

155169
{/* Gas Sponsored badge - Desktop */}
156-
{chainMetadata?.gasSponsored && (
170+
{customChainMetadata?.gasSponsored && (
157171
<div className="hidden md:block">
158172
<GasSponsoredBadge />
159173
</div>
160174
)}
161175
</div>
162176

163177
{/* description */}
164-
{chainMetadata?.about && (
165-
<p className="mb-2 whitespace-pre-line text-muted-foreground text-sm lg:text-base">
166-
{chainMetadata.about}
178+
{description && (
179+
<p className="mb-2 whitespace-pre-line text-muted-foreground text-sm lg:text-base text-pretty max-w-3xl">
180+
{description}
167181
</p>
168182
)}
169183

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/page.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,37 @@
11
import { CircleAlertIcon } from "lucide-react";
22
import { getRawAccount } from "@/api/account/get-account";
33
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
4-
import { getChain, getChainMetadata } from "../../utils";
4+
import { getChain, getCustomChainMetadata } from "../../utils";
5+
import { fetchChainSeo } from "./apis/chain-seo";
56
import { BuyFundsSection } from "./components/client/BuyFundsSection";
67
import { ChainOverviewSection } from "./components/server/ChainOverviewSection";
78
import { ChainCTA } from "./components/server/cta-card";
89
import { ExplorersSection } from "./components/server/explorer-section";
910
import { FaucetSection } from "./components/server/FaucetSection";
11+
import { FaqSection } from "./components/server/faq-section";
1012
import { SupportedProductsSection } from "./components/server/SupportedProductsSection";
1113

12-
export default async function Page(props: {
14+
type Props = {
1315
params: Promise<{ chain_id: string }>;
14-
}) {
16+
};
17+
18+
export default async function Page(props: Props) {
1519
const params = await props.params;
1620
const chain = await getChain(params.chain_id);
17-
const chainMetadata = await getChainMetadata(chain.chainId);
21+
const customChainMetadata = getCustomChainMetadata(Number(params.chain_id));
22+
const chainSeo = await fetchChainSeo(Number(chain.chainId)).catch(
23+
() => undefined,
24+
);
1825
const client = getClientThirdwebClient();
1926
const isDeprecated = chain.status === "deprecated";
20-
2127
const account = await getRawAccount();
2228

2329
return (
2430
<div className="flex flex-col gap-10">
2531
{/* Custom CTA */}
26-
{(chainMetadata?.cta?.title || chainMetadata?.cta?.description) && (
27-
<ChainCTA {...chainMetadata.cta} />
32+
{(customChainMetadata?.cta?.title ||
33+
customChainMetadata?.cta?.description) && (
34+
<ChainCTA {...customChainMetadata.cta} />
2835
)}
2936

3037
{/* Deprecated Alert */}
@@ -58,6 +65,10 @@ export default async function Page(props: {
5865
{chain.services.filter((s) => s.enabled).length > 0 && (
5966
<SupportedProductsSection services={chain.services} />
6067
)}
68+
69+
{chainSeo?.faqs && chainSeo.faqs.length > 0 && (
70+
<FaqSection faqs={chainSeo.faqs} />
71+
)}
6172
</div>
6273
);
6374
}

apps/dashboard/src/app/(app)/(dashboard)/(chain)/chainlist/components/server/chainlist-card.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { CopyTextButton } from "@/components/ui/CopyTextButton";
55
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
66
import type { ChainSupportedService } from "@/types/chain";
77
import { ChainIcon } from "../../../components/server/chain-icon";
8-
import { getChainMetadata } from "../../../utils";
8+
import { getCustomChainMetadata } from "../../../utils";
99

1010
type ChainListCardProps = {
1111
favoriteButton: JSX.Element | undefined;
@@ -18,7 +18,7 @@ type ChainListCardProps = {
1818
iconUrl?: string;
1919
};
2020

21-
export async function ChainListCard({
21+
export function ChainListCard({
2222
isDeprecated,
2323
chainId,
2424
chainName,
@@ -28,7 +28,7 @@ export async function ChainListCard({
2828
favoriteButton,
2929
iconUrl,
3030
}: ChainListCardProps) {
31-
const chainMetadata = await getChainMetadata(chainId);
31+
const customChainMetadata = getCustomChainMetadata(chainId);
3232

3333
return (
3434
<div className="relative h-full">
@@ -90,9 +90,9 @@ export async function ChainListCard({
9090
</tbody>
9191
</table>
9292

93-
{(isDeprecated || chainMetadata?.gasSponsored) && (
93+
{(isDeprecated || customChainMetadata?.gasSponsored) && (
9494
<div className="mt-5 flex gap-5 border-t pt-4">
95-
{!isDeprecated && chainMetadata?.gasSponsored && (
95+
{!isDeprecated && customChainMetadata?.gasSponsored && (
9696
<div className="flex items-center gap-1.5">
9797
<TicketCheckIcon className="size-5 text-foreground" />
9898
<p className="text-sm">Gas Sponsored</p>

0 commit comments

Comments
 (0)