Skip to content

Commit f70cc39

Browse files
ericyangpanclaude
andcommitted
feat: add VerifiedBadge and product utility components
Components: - Add VerifiedBadge component with size variants (sm/md/lg) - Uses Lucide BadgeCheck icon with low-saturation green - Supports inline, list, and hero section usage - Add RelatedProducts component for cross-product navigation - Displays related IDE/CLI/Extension products - ASCII art type indicators - Hover animations and transitions Utilities: - Create product-utils library for DRY data transformation - getPlatformInfo: Normalizes platforms/supportedIdes - transformResourceUrls: Converts manifest to component format - transformCommunityUrls: Handles null-to-undefined conversion - Enhance data fetchers with verified badge support - Add getIDE, getCLI, getExtension helpers - Memoize with React cache for metadata generation Updates: - ProductHero now supports verified badge display - Export RelatedProducts from product index Design Principles: - Follows global minimalist design (sharp corners, restrained colors) - Reusable transformation logic eliminates duplication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 9bc91b0 commit f70cc39

File tree

6 files changed

+287
-3
lines changed

6 files changed

+287
-3
lines changed

src/components/VerifiedBadge.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { BadgeCheck } from 'lucide-react'
2+
3+
export interface VerifiedBadgeProps {
4+
/**
5+
* Size variant of the badge
6+
* - sm: Small size for inline text (14px icon)
7+
* - md: Medium size for list items (16px icon)
8+
* - lg: Large size for hero sections (20px icon)
9+
*/
10+
size?: 'sm' | 'md' | 'lg'
11+
/**
12+
* Additional CSS classes
13+
*/
14+
className?: string
15+
}
16+
17+
/**
18+
* VerifiedBadge component displays a badge-check icon with low-saturation green color
19+
* to indicate verified products, models, providers, or vendors.
20+
*/
21+
export function VerifiedBadge({ size = 'md', className = '' }: VerifiedBadgeProps) {
22+
const sizeClasses = {
23+
sm: 'w-3.5 h-3.5', // 14px
24+
md: 'w-4 h-4', // 16px
25+
lg: 'w-5 h-5', // 20px
26+
}
27+
28+
return (
29+
<BadgeCheck
30+
className={`${sizeClasses[size]} text-[#16a34a] flex-shrink-0 ${className}`}
31+
aria-label="Verified"
32+
strokeWidth={2}
33+
/>
34+
)
35+
}

src/components/product/ProductHero.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useTranslations } from 'next-intl'
44
import React from 'react'
5+
import { VerifiedBadge } from '@/components/VerifiedBadge'
56
import { renderLicense } from '@/lib/license'
67

78
export interface ProductHeroProps {
@@ -11,6 +12,7 @@ export interface ProductHeroProps {
1112
vendor?: string
1213
category: 'CLI' | 'IDE' | 'MCP' | 'PROVIDER' | 'MODEL' | 'VENDOR'
1314
categoryLabel?: string // Optional custom label for the badge
15+
verified?: boolean // Whether the product is verified
1416

1517
// Metadata
1618
latestVersion?: string
@@ -73,6 +75,7 @@ export function ProductHero({
7375
vendor,
7476
category,
7577
categoryLabel,
78+
verified = false,
7679
latestVersion,
7780
license,
7881
githubStars,
@@ -103,10 +106,13 @@ export function ProductHero({
103106
return (
104107
<section className="py-[var(--spacing-lg)] border-b border-[var(--color-border)]">
105108
{/* Title and Description Container - Max 800px */}
106-
<div className="max-w-8xl mx-auto px-[var(--spacing-md)] text-center">
109+
<div className="max-w-6xl mx-auto px-[var(--spacing-md)] text-center">
107110
{/* Title with Badge */}
108111
<div className="relative inline-block mb-[var(--spacing-sm)]">
109-
<h1 className="text-5xl font-semibold tracking-[-0.04em] detail-page-h1">{name}</h1>
112+
<div className="flex items-center justify-center gap-[var(--spacing-xs)]">
113+
<h1 className="text-5xl font-semibold tracking-[-0.04em] detail-page-h1">{name}</h1>
114+
{verified && <VerifiedBadge size="lg" />}
115+
</div>
110116
<div className="absolute bottom-0 right-0 translate-x-[calc(100%+1rem)]">
111117
<div className="px-[var(--spacing-xs)] py-[2px] text-xs text-[var(--color-text-muted)] border-[1.5px] border-double border-[var(--color-border-strong)] whitespace-nowrap">
112118
{badgeText}
@@ -199,7 +205,7 @@ export function ProductHero({
199205
)}
200206

201207
{/* Rest of the content - using the same container pattern */}
202-
<div className="max-w-8xl mx-auto px-[var(--spacing-md)] text-center">
208+
<div className="max-w-6xl mx-auto px-[var(--spacing-md)] text-center">
203209
{/* Platforms */}
204210
{displayPlatforms && displayPlatforms.length > 0 && (
205211
<div className="flex justify-center mb-[var(--spacing-lg)]">
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
'use client'
2+
3+
import { useTranslations } from 'next-intl'
4+
import { Link } from '@/i18n/navigation'
5+
import type { ManifestCLI, ManifestExtension, ManifestIDE } from '@/types/manifests'
6+
7+
export interface RelatedProductsProps {
8+
products: Array<{
9+
type: 'ide' | 'cli' | 'extension'
10+
data: ManifestIDE | ManifestCLI | ManifestExtension | null
11+
}>
12+
}
13+
14+
export function RelatedProducts({ products }: RelatedProductsProps) {
15+
const t = useTranslations('components.relatedProducts')
16+
17+
// Filter out products with null data
18+
const validProducts = products.filter(p => p.data !== null)
19+
20+
if (validProducts.length === 0) {
21+
return null
22+
}
23+
24+
// Get type label
25+
const getTypeLabel = (type: 'ide' | 'cli' | 'extension') => {
26+
switch (type) {
27+
case 'ide':
28+
return t('ide')
29+
case 'cli':
30+
return t('cli')
31+
case 'extension':
32+
return t('extension')
33+
}
34+
}
35+
36+
// Get type route
37+
const getTypeRoute = (type: 'ide' | 'cli' | 'extension') => {
38+
switch (type) {
39+
case 'ide':
40+
return 'ides'
41+
case 'cli':
42+
return 'clis'
43+
case 'extension':
44+
return 'extensions'
45+
}
46+
}
47+
48+
// Get ASCII art for type
49+
const getTypeAsciiArt = (type: 'ide' | 'cli' | 'extension') => {
50+
switch (type) {
51+
case 'ide':
52+
return `┌─────┐
53+
│ IDE │
54+
└─────┘`
55+
case 'cli':
56+
return `┌─────┐
57+
│ CLI │
58+
└─────┘`
59+
case 'extension':
60+
return `┌─────┬───┐
61+
│ EXT │ ⚡ │
62+
└─────┴───┘`
63+
}
64+
}
65+
66+
return (
67+
<section className="py-[var(--spacing-lg)] border-b border-[var(--color-border)]">
68+
<div className="max-w-8xl mx-auto px-[var(--spacing-md)]">
69+
<h2 className="text-xl font-semibold tracking-tight mb-[var(--spacing-md)]">
70+
{t('title')}
71+
</h2>
72+
73+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[var(--spacing-md)]">
74+
{validProducts.map(({ type, data }) => {
75+
if (!data) return null
76+
77+
return (
78+
<Link
79+
key={`${type}-${data.id}`}
80+
href={`/${getTypeRoute(type)}/${data.id}`}
81+
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"
82+
>
83+
<div className="flex items-center justify-between mb-[var(--spacing-sm)]">
84+
<div className="flex items-center gap-[var(--spacing-sm)]">
85+
<pre className="text-xs leading-tight text-[var(--color-text-muted)]">
86+
{getTypeAsciiArt(type)}
87+
</pre>
88+
<div>
89+
<p className="text-xs text-[var(--color-text-muted)] uppercase tracking-wider font-medium mb-1">
90+
{getTypeLabel(type)}
91+
</p>
92+
<h3 className="text-lg font-semibold tracking-tight">{data.name}</h3>
93+
<p className="text-xs text-[var(--color-text-muted)]">{data.vendor}</p>
94+
</div>
95+
</div>
96+
<span className="text-lg text-[var(--color-text-muted)] group-hover:text-[var(--color-text)] group-hover:translate-x-1 transition-all">
97+
98+
</span>
99+
</div>
100+
<p className="text-sm text-[var(--color-text-secondary)] font-light line-clamp-2">
101+
{data.description}
102+
</p>
103+
</Link>
104+
)
105+
})}
106+
</div>
107+
</div>
108+
</section>
109+
)
110+
}

src/components/product/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export {
1111
type resourceUrls,
1212
} from './ProductLinks'
1313
export { ProductPricing, type ProductPricingProps } from './ProductPricing'
14+
export { RelatedProducts, type RelatedProductsProps } from './RelatedProducts'

src/lib/data/fetchers.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
ManifestIDE,
1818
ManifestModel,
1919
ManifestProvider,
20+
ManifestRelatedProduct,
2021
ManifestVendor,
2122
} from '@/types/manifests'
2223

@@ -99,6 +100,42 @@ export const getModelProvider = cache(async (slug: string, locale: Locale) => {
99100
) as unknown as ManifestProvider
100101
})
101102

103+
/**
104+
* Cached fetcher for related products
105+
* Fetches multiple related products (IDE/CLI/Extension) from relatedProducts array
106+
* Uses React cache() to prevent duplicate fetching
107+
*/
108+
export const getRelatedProducts = cache(
109+
async (
110+
relatedProducts: ManifestRelatedProduct[],
111+
locale: Locale
112+
): Promise<
113+
Array<{
114+
type: 'ide' | 'cli' | 'extension'
115+
data: ManifestIDE | ManifestCLI | ManifestExtension | null
116+
}>
117+
> => {
118+
if (!relatedProducts || relatedProducts.length === 0) {
119+
return []
120+
}
121+
122+
return Promise.all(
123+
relatedProducts.map(async rel => {
124+
try {
125+
let data = null
126+
if (rel.type === 'ide') data = await getIDE(rel.productId, locale)
127+
else if (rel.type === 'cli') data = await getCLI(rel.productId, locale)
128+
else if (rel.type === 'extension') data = await getExtension(rel.productId, locale)
129+
130+
return { type: rel.type, data }
131+
} catch {
132+
return { type: rel.type, data: null }
133+
}
134+
})
135+
)
136+
}
137+
)
138+
102139
/**
103140
* Cached fetcher for Article data
104141
* Articles are already locale-aware through getArticleBySlug

src/lib/product-utils.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Product Utility Functions
3+
* Provides data transformation utilities for product pages
4+
*/
5+
6+
import type {
7+
ComponentCommunityUrls,
8+
ComponentResourceUrls,
9+
ManifestCLI,
10+
ManifestCommunityUrls,
11+
ManifestExtension,
12+
ManifestIDE,
13+
ManifestResourceUrls,
14+
} from '@/types/manifests'
15+
16+
/**
17+
* Platform information type
18+
*/
19+
export type PlatformInfo =
20+
| {
21+
type: 'platforms'
22+
values: string[]
23+
}
24+
| {
25+
type: 'supportedIdes'
26+
values: string[]
27+
}
28+
| null
29+
30+
/**
31+
* Get normalized platform information from a product
32+
* Handles both platforms (for IDE/CLI) and supportedIdes (for Extension)
33+
*/
34+
export function getPlatformInfo(
35+
product: ManifestIDE | ManifestCLI | ManifestExtension
36+
): PlatformInfo {
37+
// Check for platforms field (IDE/CLI)
38+
if ('platforms' in product && product.platforms && product.platforms.length > 0) {
39+
return {
40+
type: 'platforms',
41+
values: product.platforms.map(p => p.os),
42+
}
43+
}
44+
45+
// Check for supportedIdes field (Extension)
46+
if ('supportedIdes' in product && product.supportedIdes && product.supportedIdes.length > 0) {
47+
return {
48+
type: 'supportedIdes',
49+
values: product.supportedIdes.map(ide => ide.ideId),
50+
}
51+
}
52+
53+
return null
54+
}
55+
56+
/**
57+
* Transform resource URLs from manifest format to component format
58+
* Converts null to undefined for optional component props
59+
*/
60+
export function transformResourceUrls(
61+
resourceUrls?: ManifestResourceUrls | null
62+
): ComponentResourceUrls {
63+
if (!resourceUrls) {
64+
return {}
65+
}
66+
67+
return {
68+
download: resourceUrls.download || undefined,
69+
changelog: resourceUrls.changelog || undefined,
70+
pricing: resourceUrls.pricing || undefined,
71+
mcp: resourceUrls.mcp || undefined,
72+
issue: resourceUrls.issue || undefined,
73+
}
74+
}
75+
76+
/**
77+
* Transform community URLs from manifest format to component format
78+
* Converts null to undefined for optional component props
79+
*/
80+
export function transformCommunityUrls(
81+
communityUrls?: ManifestCommunityUrls | null
82+
): ComponentCommunityUrls {
83+
if (!communityUrls) {
84+
return {}
85+
}
86+
87+
return {
88+
linkedin: communityUrls.linkedin || undefined,
89+
twitter: communityUrls.twitter || undefined,
90+
github: communityUrls.github || undefined,
91+
youtube: communityUrls.youtube || undefined,
92+
discord: communityUrls.discord || undefined,
93+
reddit: communityUrls.reddit || undefined,
94+
}
95+
}

0 commit comments

Comments
 (0)