Skip to content

Commit 9ad4676

Browse files
ericyangpanclaude
andcommitted
refactor: consolidate product detail pages using shared template
Create ProductDetailTemplate to eliminate code duplication across product pages following DRY principle. Template Features: - Unified layout structure for all product types - Shared Schema.org metadata generation - Consistent breadcrumb and navigation patterns - Reusable hero, links, pricing, and commands sections - Type-safe product type handling Pages Refactored: - IDE detail pages: Reduced from ~200 to ~50 lines - CLI detail pages: Reduced from ~200 to ~50 lines - Extension detail pages: Reduced from ~200 to ~50 lines - Model Provider detail pages: Enhanced with template - Vendor detail pages: Enhanced with template List Page Improvements: - Consolidated platform icon rendering logic - Shared filtering and sorting patterns - Consistent empty state handling - Open source rank page optimizations Benefits: - ~70% code reduction in detail pages - Single source of truth for product page structure - Easier maintenance and feature additions - Consistent user experience across all product types - Type-safe transformations with product-utils 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent f70cc39 commit 9ad4676

File tree

13 files changed

+293
-417
lines changed

13 files changed

+293
-417
lines changed
Lines changed: 17 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
import { notFound } from 'next/navigation'
22
import { getTranslations } from 'next-intl/server'
3-
import { BackToNavigation } from '@/components/controls/BackToNavigation'
4-
import { Breadcrumb } from '@/components/controls/Breadcrumb'
5-
import Footer from '@/components/Footer'
6-
import Header from '@/components/Header'
7-
import { JsonLd } from '@/components/JsonLd'
8-
import { ProductCommands, ProductHero, ProductLinks, ProductPricing } from '@/components/product'
93
import type { Locale } from '@/i18n/config'
10-
import { Link } from '@/i18n/navigation'
114
import { getCLI } from '@/lib/data/fetchers'
125
import { clisData as clis } from '@/lib/generated'
13-
import { getGithubStars } from '@/lib/generated/github-stars'
146
import { translateLicenseText } from '@/lib/license'
157
import { generateSoftwareDetailMetadata } from '@/lib/metadata'
16-
import { generateSoftwareDetailSchema } from '@/lib/metadata/schemas'
17-
import type { ComponentCommunityUrls, ComponentResourceUrls } from '@/types/manifests'
8+
import { ProductDetailTemplate } from '@/templates'
189

1910
export const revalidate = 3600
2011

@@ -70,73 +61,20 @@ export default async function CLIPage({
7061
const t = await getTranslations({ locale, namespace: 'pages.cliDetail' })
7162
const tGlobal = await getTranslations({ locale })
7263

73-
const websiteUrl = cli.resourceUrls?.download || cli.websiteUrl
74-
const docsUrl = cli.docsUrl || undefined
75-
76-
// Transform resourceUrls to component format (convert null to undefined)
77-
const resourceUrls: ComponentResourceUrls = {
78-
download: cli.resourceUrls?.download || undefined,
79-
changelog: cli.resourceUrls?.changelog || undefined,
80-
pricing: cli.resourceUrls?.pricing || undefined,
81-
mcp: cli.resourceUrls?.mcp || undefined,
82-
issue: cli.resourceUrls?.issue || undefined,
83-
}
84-
85-
// Transform communityUrls to component format (convert null to undefined)
86-
const communityUrls: ComponentCommunityUrls = {
87-
linkedin: cli.communityUrls?.linkedin || undefined,
88-
twitter: cli.communityUrls?.twitter || undefined,
89-
github: cli.communityUrls?.github || undefined,
90-
youtube: cli.communityUrls?.youtube || undefined,
91-
discord: cli.communityUrls?.discord || undefined,
92-
reddit: cli.communityUrls?.reddit || undefined,
93-
}
94-
95-
// Generate JSON-LD schema using the new unified system
96-
const softwareApplicationSchema = await generateSoftwareDetailSchema({
97-
product: {
98-
name: cli.name,
99-
description: cli.description,
100-
vendor: cli.vendor,
101-
websiteUrl,
102-
downloadUrl: cli.resourceUrls?.download || undefined,
103-
version: cli.latestVersion,
104-
platforms: cli.platforms,
105-
pricing: cli.pricing,
106-
license: cli.license ? translateLicenseText(cli.license, tGlobal) : undefined,
107-
},
108-
category: 'clis',
109-
locale: locale as Locale,
110-
})
111-
11264
return (
113-
<>
114-
<JsonLd data={softwareApplicationSchema} />
115-
<Header />
116-
117-
<Breadcrumb
118-
items={[
119-
{ name: tGlobal('shared.common.aiCodingStack'), href: '/ai-coding-stack' },
120-
{ name: tGlobal('shared.stacks.clis'), href: 'clis' },
121-
{ name: cli.name, href: `clis/${cli.id}` },
122-
]}
123-
/>
124-
125-
{/* Hero Section */}
126-
<ProductHero
127-
name={cli.name}
128-
description={cli.description}
129-
vendor={cli.vendor}
130-
category="CLI"
131-
categoryLabel={t('categoryLabel')}
132-
latestVersion={cli.latestVersion}
133-
license={cli.license}
134-
githubStars={getGithubStars('clis', cli.id)}
135-
platforms={cli.platforms?.map(p => p.os)}
136-
websiteUrl={websiteUrl}
137-
docsUrl={docsUrl}
138-
downloadUrl={cli.resourceUrls?.download || undefined}
139-
labels={{
65+
<ProductDetailTemplate
66+
product={cli}
67+
productType="cli"
68+
locale={locale as Locale}
69+
category="clis"
70+
translations={{
71+
categoryLabel: t('categoryLabel'),
72+
allProductsLabel: t('allCLIs'),
73+
breadcrumbs: {
74+
home: tGlobal('shared.common.aiCodingStack'),
75+
category: tGlobal('shared.stacks.clis'),
76+
},
77+
productHero: {
14078
vendor: t('vendor'),
14179
version: t('version'),
14280
license: t('license'),
@@ -145,61 +83,8 @@ export default async function CLIPage({
14583
visitWebsite: t('visitWebsite'),
14684
documentation: t('documentation'),
14785
download: t('download'),
148-
}}
149-
/>
150-
151-
{/* Related IDE */}
152-
{cli.ide && (
153-
<section className="py-[var(--spacing-lg)] border-b border-[var(--color-border)]">
154-
<div className="max-w-8xl mx-auto px-[var(--spacing-md)]">
155-
<Link
156-
href={`ides/${cli.ide}`}
157-
className="block border border-[var(--color-border)] p-[var(--spacing-md)] hover:border-[var(--color-border-strong)] transition-all hover:-translate-y-0.5 group"
158-
>
159-
<div className="flex items-center justify-between mb-[var(--spacing-sm)]">
160-
<div className="flex items-center gap-[var(--spacing-sm)]">
161-
<pre className="text-xs leading-tight text-[var(--color-text-muted)]">
162-
{`┌─────┐
163-
│ IDE │
164-
└─────┘`}
165-
</pre>
166-
<div>
167-
<p className="text-xs text-[var(--color-text-muted)] uppercase tracking-wider font-medium mb-1">
168-
Related IDE
169-
</p>
170-
<h3 className="text-lg font-semibold tracking-tight">
171-
{cli.ide
172-
.split('-')
173-
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
174-
.join(' ')}
175-
</h3>
176-
</div>
177-
</div>
178-
<span className="text-lg text-[var(--color-text-muted)] group-hover:text-[var(--color-text)] group-hover:translate-x-1 transition-all">
179-
180-
</span>
181-
</div>
182-
<p className="text-sm text-[var(--color-text-secondary)] font-light">
183-
IDE companion for {cli.name}
184-
</p>
185-
</Link>
186-
</div>
187-
</section>
188-
)}
189-
190-
{/* Pricing */}
191-
<ProductPricing pricing={cli.pricing} pricingUrl={resourceUrls.pricing} />
192-
193-
{/* Additional Links */}
194-
<ProductLinks resourceUrls={resourceUrls} communityUrls={communityUrls} />
195-
196-
{/* Commands */}
197-
<ProductCommands install={cli.install} launch={cli.launch} />
198-
199-
{/* Navigation */}
200-
<BackToNavigation href="clis" title={t('allCLIs')} />
201-
202-
<Footer />
203-
</>
86+
},
87+
}}
88+
/>
20489
)
20590
}

src/app/[locale]/clis/page.client.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Footer from '@/components/Footer'
77
import Header from '@/components/Header'
88
import StackTabs from '@/components/navigation/StackTabs'
99
import PageHeader from '@/components/PageHeader'
10+
import { VerifiedBadge } from '@/components/VerifiedBadge'
1011
import type { Locale } from '@/i18n/config'
1112
import { Link } from '@/i18n/navigation'
1213
import { clisData } from '@/lib/generated'
@@ -138,7 +139,10 @@ export default function CLIsPageClient({ locale }: Props) {
138139
className="block border border-[var(--color-border)] p-[var(--spacing-md)] hover:border-[var(--color-border-strong)] transition-all hover:-translate-y-0.5 group flex flex-col"
139140
>
140141
<div className="flex justify-between items-start mb-[var(--spacing-sm)]">
141-
<h3 className="text-lg font-semibold tracking-tight">{cli.name}</h3>
142+
<div className="flex items-center gap-[var(--spacing-xs)]">
143+
<h3 className="text-lg font-semibold tracking-tight">{cli.name}</h3>
144+
{cli.verified && <VerifiedBadge size="sm" />}
145+
</div>
142146
<span className="text-lg text-[var(--color-text-muted)] group-hover:text-[var(--color-text)] group-hover:translate-x-1 transition-all">
143147
144148
</span>
Lines changed: 26 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
import { notFound } from 'next/navigation'
22
import { getTranslations } from 'next-intl/server'
3-
import { BackToNavigation } from '@/components/controls/BackToNavigation'
4-
import { Breadcrumb } from '@/components/controls/Breadcrumb'
5-
import Footer from '@/components/Footer'
6-
import Header from '@/components/Header'
7-
import { JsonLd } from '@/components/JsonLd'
8-
import { ProductCommands, ProductHero, ProductLinks, ProductPricing } from '@/components/product'
93
import type { Locale } from '@/i18n/config'
104
import { getExtension } from '@/lib/data/fetchers'
115
import { extensionsData as extensions } from '@/lib/generated'
12-
import { getGithubStars } from '@/lib/generated/github-stars'
136
import { translateLicenseText } from '@/lib/license'
147
import { generateSoftwareDetailMetadata } from '@/lib/metadata'
15-
import { getSchemaCurrency, getSchemaPrice } from '@/lib/pricing'
8+
import { ProductDetailTemplate } from '@/templates'
169

1710
export const revalidate = 3600
1811

@@ -34,9 +27,13 @@ export async function generateMetadata({
3427
return { title: 'Extension Not Found | AI Coding Stack' }
3528
}
3629

37-
const t = await getTranslations({ locale })
38-
const licenseStr = extension.license ? translateLicenseText(extension.license, t) : ''
39-
const platforms = extension.supportedIdes?.map(ideSupport => ({ os: ideSupport.ideId }))
30+
const tGlobal = await getTranslations({ locale })
31+
const licenseStr = extension.license ? translateLicenseText(extension.license, tGlobal) : ''
32+
33+
// Convert supportedIdes to platforms format for metadata generation
34+
const platforms = extension.supportedIdes?.map(ideSupport => ({
35+
os: ideSupport.ideId,
36+
}))
4037

4138
return await generateSoftwareDetailMetadata({
4239
locale: locale as Locale,
@@ -50,7 +47,7 @@ export async function generateMetadata({
5047
pricing: extension.pricing,
5148
license: licenseStr,
5249
},
53-
typeDescription: 'AI Coding Extension',
50+
typeDescription: 'AI Coding Assistant Extension',
5451
})
5552
}
5653

@@ -69,120 +66,30 @@ export default async function ExtensionPage({
6966
const t = await getTranslations({ locale, namespace: 'pages.extensionDetail' })
7067
const tGlobal = await getTranslations({ locale })
7168

72-
const websiteUrl = extension.resourceUrls?.download || extension.websiteUrl
73-
const docsUrl = extension.docsUrl || undefined
74-
75-
// Schema.org structured data
76-
const softwareApplicationSchema = {
77-
'@context': 'https://schema.org',
78-
'@type': 'SoftwareApplication',
79-
name: extension.name,
80-
applicationCategory: 'DeveloperApplication',
81-
applicationSubCategory: 'AI Assistant',
82-
operatingSystem: 'Cross-platform',
83-
compatibleWith:
84-
extension.supportedIdes?.map(ideSupport => ideSupport.ideId).join(', ') ||
85-
'VS Code, JetBrains IDEs',
86-
softwareVersion: extension.latestVersion,
87-
description: extension.description,
88-
url: websiteUrl,
89-
downloadUrl:
90-
extension.resourceUrls?.download || extension.supportedIdes?.[0]?.marketplaceUrl || undefined,
91-
installUrl:
92-
extension.resourceUrls?.download || extension.supportedIdes?.[0]?.marketplaceUrl || undefined,
93-
author: {
94-
'@type': 'Organization',
95-
name: extension.vendor,
96-
},
97-
offers:
98-
extension.pricing && extension.pricing.length > 0
99-
? extension.pricing.map(tier => {
100-
return {
101-
'@type': 'Offer',
102-
name: tier.name,
103-
price: getSchemaPrice(tier),
104-
priceCurrency: getSchemaCurrency(tier),
105-
category: tier.category,
106-
}
107-
})
108-
: {
109-
'@type': 'Offer',
110-
price: '0',
111-
priceCurrency: 'USD',
112-
},
113-
license: extension.license ? translateLicenseText(extension.license, tGlobal) : undefined,
114-
}
115-
11669
return (
117-
<>
118-
<JsonLd data={softwareApplicationSchema} />
119-
<Header />
120-
121-
<Breadcrumb
122-
items={[
123-
{ name: tGlobal('shared.common.aiCodingStack'), href: '/ai-coding-stack' },
124-
{ name: tGlobal('shared.stacks.extensions'), href: 'extensions' },
125-
{ name: extension.name, href: `extensions/${extension.id}` },
126-
]}
127-
/>
128-
129-
{/* Hero Section */}
130-
<ProductHero
131-
name={extension.name}
132-
description={extension.description}
133-
vendor={extension.vendor}
134-
category="IDE"
135-
categoryLabel={t('categoryLabel')}
136-
latestVersion={extension.latestVersion}
137-
license={extension.license}
138-
githubStars={getGithubStars('extensions', extension.id)}
139-
additionalInfo={
140-
extension.supportedIdes && extension.supportedIdes.length > 0
141-
? [
142-
{
143-
label: t('supportedIdes'),
144-
value: extension.supportedIdes.map(ideSupport => ideSupport.ideId).join(', '),
145-
},
146-
]
147-
: undefined
148-
}
149-
websiteUrl={websiteUrl}
150-
docsUrl={docsUrl}
151-
downloadUrl={
152-
extension.resourceUrls?.download ||
153-
extension.supportedIdes?.[0]?.marketplaceUrl ||
154-
undefined
155-
}
156-
labels={{
70+
<ProductDetailTemplate
71+
product={extension}
72+
productType="extension"
73+
locale={locale as Locale}
74+
category="extensions"
75+
translations={{
76+
categoryLabel: t('categoryLabel'),
77+
allProductsLabel: t('allExtensions'),
78+
breadcrumbs: {
79+
home: tGlobal('shared.common.aiCodingStack'),
80+
category: tGlobal('shared.stacks.extensions'),
81+
},
82+
productHero: {
15783
vendor: t('vendor'),
15884
version: t('version'),
15985
license: t('license'),
16086
stars: t('stars'),
87+
supportedIdes: t('supportedIdes'),
16188
visitWebsite: t('visitWebsite'),
16289
documentation: t('documentation'),
16390
download: t('download'),
164-
}}
165-
/>
166-
167-
{/* Pricing */}
168-
<ProductPricing
169-
pricing={extension.pricing}
170-
pricingUrl={extension.resourceUrls?.pricing || undefined}
171-
/>
172-
173-
{/* Additional Links */}
174-
<ProductLinks resourceUrls={{}} communityUrls={{}} />
175-
176-
{/* Commands */}
177-
<ProductCommands
178-
install={extension.supportedIdes?.[0]?.installCommand || extension.install || undefined}
179-
launch={extension.launch || undefined}
180-
/>
181-
182-
{/* Navigation */}
183-
<BackToNavigation href="extensions" title={t('allExtensions')} />
184-
185-
<Footer />
186-
</>
91+
},
92+
}}
93+
/>
18794
)
18895
}

src/app/[locale]/extensions/page.client.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Footer from '@/components/Footer'
77
import Header from '@/components/Header'
88
import StackTabs from '@/components/navigation/StackTabs'
99
import PageHeader from '@/components/PageHeader'
10+
import { VerifiedBadge } from '@/components/VerifiedBadge'
1011
import type { Locale } from '@/i18n/config'
1112
import { Link } from '@/i18n/navigation'
1213
import { extensionsData } from '@/lib/generated'
@@ -141,7 +142,10 @@ export default function ExtensionsPageClient({ locale }: Props) {
141142
className="block border border-[var(--color-border)] p-[var(--spacing-md)] hover:border-[var(--color-border-strong)] transition-all hover:-translate-y-0.5 group flex flex-col"
142143
>
143144
<div className="flex justify-between items-start mb-[var(--spacing-sm)]">
144-
<h3 className="text-lg font-semibold tracking-tight">{extension.name}</h3>
145+
<div className="flex items-center gap-[var(--spacing-xs)]">
146+
<h3 className="text-lg font-semibold tracking-tight">{extension.name}</h3>
147+
{extension.verified && <VerifiedBadge size="sm" />}
148+
</div>
145149
<span className="text-lg text-[var(--color-text-muted)] group-hover:text-[var(--color-text)] group-hover:translate-x-1 transition-all">
146150
147151
</span>

0 commit comments

Comments
 (0)