feat(dashboard): add marketing product pages (#649)

This commit is contained in:
shenlan 2025-11-09 15:44:38 +08:00 committed by GitHub
parent 21d7f40fc5
commit 895d5727b5
19 changed files with 8869 additions and 4742 deletions

View File

@ -0,0 +1,190 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import html2canvas from 'html2canvas'
type QRCodeToCanvas = (
canvas: HTMLCanvasElement,
text: string,
options?: Record<string, unknown>
) => Promise<unknown>
import ProductCommunity from '@components/marketing/ProductCommunity'
import ProductDocs from '@components/marketing/ProductDocs'
import ProductDownload from '@components/marketing/ProductDownload'
import ProductEditions from '@components/marketing/ProductEditions'
import ProductFaq from '@components/marketing/ProductFaq'
import ProductFeatures from '@components/marketing/ProductFeatures'
import ProductHero from '@components/marketing/ProductHero'
import ProductScenarios from '@components/marketing/ProductScenarios'
import type { ProductConfig } from '@src/products/registry'
export type Lang = 'zh' | 'en'
const drawQR = async (
node: HTMLElement | null,
url: string,
size: number = 180
) => {
if (!node) {
return
}
node.innerHTML = ''
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
try {
const { toCanvas } = (await import('qrcode')) as unknown as {
toCanvas: QRCodeToCanvas
}
await toCanvas(canvas, url, {
width: size,
margin: 1,
errorCorrectionLevel: 'H',
})
node.appendChild(canvas)
} catch (error) {
console.error('Failed to render QR code', error)
}
}
const exportPoster = async (node: HTMLElement | null, slug: string) => {
if (!node) {
return
}
const canvas = await html2canvas(node, {
backgroundColor: '#ffffff',
scale: 2,
useCORS: true,
})
const link = document.createElement('a')
const date = new Date().toISOString().slice(0, 10)
link.download = `${slug}-poster-${date}.png`
link.href = canvas.toDataURL('image/png')
link.click()
}
type ClientProps = {
config: ProductConfig
}
export default function Client({ config }: ClientProps) {
const [lang, setLang] = useState<Lang>('zh')
const defaultQrUrl = useMemo(
() => `https://www.svc.plus/${config.slug}/`,
[config.slug]
)
const [qrUrl, setQrUrl] = useState(defaultQrUrl)
const qrRef = useRef<HTMLDivElement>(null)
const posterRef = useRef<HTMLDivElement>(null)
const posterQrRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (typeof navigator !== 'undefined') {
const preferred = navigator.language?.toLowerCase().startsWith('en')
? 'en'
: 'zh'
setLang(preferred)
}
}, [])
useEffect(() => {
setQrUrl(defaultQrUrl)
}, [defaultQrUrl])
useEffect(() => {
drawQR(qrRef.current, qrUrl, 180)
drawQR(posterQrRef.current, qrUrl, 220)
}, [qrUrl])
const handleToggleLanguage = useCallback(() => {
setLang((current) => (current === 'zh' ? 'en' : 'zh'))
}, [])
const handleExportPoster = useCallback(() => {
return exportPoster(posterRef.current, config.slug)
}, [config.slug])
const handleQrUpdate = useCallback(
(value: string) => {
setQrUrl(value || defaultQrUrl)
},
[defaultQrUrl]
)
return (
<div className="bg-white text-slate-900">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-2 text-sm font-semibold">
<span className="text-slate-500">SVC.plus</span>
<span className="text-slate-400">/</span>
<span className="text-brand-dark">{config.name}</span>
</Link>
<nav className="hidden gap-6 text-sm font-medium text-slate-600 md:flex">
<Link href="#features" scroll>
{lang === 'zh' ? '核心功能' : 'Features'}
</Link>
<Link href="#editions" scroll>
{lang === 'zh' ? '版本与部署' : 'Editions'}
</Link>
<Link href="#scenarios" scroll>
{lang === 'zh' ? '应用场景' : 'Scenarios'}
</Link>
<Link href="#download" scroll>
{lang === 'zh' ? '下载' : 'Download'}
</Link>
<Link href="#docs" scroll>
{lang === 'zh' ? '文档' : 'Docs'}
</Link>
<Link href="#faq" scroll>
{lang === 'zh' ? 'FAQ' : 'FAQ'}
</Link>
</nav>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleToggleLanguage}
className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50"
aria-label={lang === 'zh' ? 'Switch to English' : '切换到中文'}
>
{lang === 'zh' ? 'EN' : '中文'}
</button>
<Link
href="#download"
scroll
className="rounded-lg bg-brand px-4 py-2 text-xs font-semibold text-white shadow-sm transition hover:bg-brand-dark"
>
{lang === 'zh' ? '立即下载' : 'Download'}
</Link>
</div>
</div>
</header>
<main>
<ProductHero config={config} lang={lang} onExportPoster={handleExportPoster} />
<ProductFeatures lang={lang} />
<ProductEditions config={config} lang={lang} />
<ProductScenarios lang={lang} />
<ProductDownload config={config} lang={lang} />
<ProductDocs config={config} lang={lang} />
<ProductFaq lang={lang} />
<ProductCommunity
config={config}
lang={lang}
qrUrl={qrUrl}
onQrUrlChange={handleQrUpdate}
qrRef={qrRef}
posterRef={posterRef}
posterQrRef={posterQrRef}
onExportPoster={handleExportPoster}
/>
</main>
</div>
)
}

View File

@ -0,0 +1,56 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Client from './Client'
import { PRODUCT_MAP, getAllSlugs } from '@src/products/registry'
type PageProps = {
params: {
product: string
}
}
export async function generateStaticParams() {
return getAllSlugs().map((slug) => ({ product: slug }))
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const config = PRODUCT_MAP.get(params.product)
if (!config) {
return {}
}
const description = `${config.name}${config.tagline_en}`
const canonical = `https://www.svc.plus/${config.slug}`
return {
title: config.title,
description,
alternates: {
canonical,
},
openGraph: {
title: config.title_en,
description,
images: [config.ogImage],
url: canonical,
},
twitter: {
card: 'summary_large_image',
title: config.title_en,
description,
images: [config.ogImage],
},
}
}
export default function ProductPage({ params }: PageProps) {
const config = PRODUCT_MAP.get(params.product)
if (!config) {
notFound()
}
return <Client config={config} />
}

View File

@ -0,0 +1,168 @@
import { Github, MessageCircle, Newspaper, PlayCircle, QrCode } from 'lucide-react'
import Link from 'next/link'
import { forwardRef } from 'react'
import type { ProductConfig } from '@src/products/registry'
type ProductCommunityProps = {
config: ProductConfig
lang: 'zh' | 'en'
qrUrl: string
onQrUrlChange: (value: string) => void
qrRef: React.RefObject<HTMLDivElement>
posterRef: React.RefObject<HTMLDivElement>
posterQrRef: React.RefObject<HTMLDivElement>
onExportPoster: () => void | Promise<void>
}
const Poster = forwardRef<HTMLDivElement, { config: ProductConfig; lang: 'zh' | 'en'; posterQrRef: React.RefObject<HTMLDivElement> }>(
({ config, lang, posterQrRef }, ref) => {
const tagline = lang === 'zh' ? config.tagline_zh : config.tagline_en
return (
<div
ref={ref}
className="fixed top-0 left-0 -translate-x-[150vw] w-[720px] max-w-full bg-white p-10 text-slate-900"
aria-hidden="true"
>
<div className="flex h-full w-full flex-col justify-between rounded-3xl border border-slate-200 bg-gradient-to-b from-brand-surface to-white p-10 shadow-xl">
<div>
<div className="flex items-center gap-3 text-xl font-semibold text-slate-900">
<QrCode className="h-6 w-6 text-brand-dark" aria-hidden="true" />
<span>SVC.plus / {config.name}</span>
</div>
<h2 className="mt-8 text-5xl font-extrabold text-slate-900">
{lang === 'zh' ? config.title : config.title_en}
</h2>
<p className="mt-4 text-lg text-slate-700">{tagline}</p>
<ul className="mt-6 space-y-2 text-base text-slate-700">
<li> {lang === 'zh' ? 'Windows / macOS / Linux 全平台支持' : 'Windows / macOS / Linux support'}</li>
<li> {lang === 'zh' ? '官网 / 下载' : 'Website / Downloads'}: https://www.svc.plus/{config.slug}/</li>
</ul>
</div>
<div className="flex items-end justify-between">
<div>
<p className="text-sm text-slate-600">
{lang === 'zh' ? '长按识别二维码' : 'Scan the QR code'}
</p>
<p className="text-2xl font-semibold text-slate-900">
{lang === 'zh' ? '立即开始加速' : 'Start accelerating now'}
</p>
</div>
<div ref={posterQrRef} className="rounded-2xl border border-slate-200 bg-white p-4" />
</div>
</div>
</div>
)
}
)
Poster.displayName = 'Poster'
export default function ProductCommunity({
config,
lang,
qrUrl,
onQrUrlChange,
qrRef,
posterRef,
posterQrRef,
onExportPoster,
}: ProductCommunityProps) {
return (
<section id="community" aria-labelledby="community-title" className="bg-slate-50 py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="grid gap-10 lg:grid-cols-2">
<div>
<h2 id="community-title" className="text-3xl font-bold text-slate-900">
{lang === 'zh' ? '社区与支持' : 'Community & Support'}
</h2>
<p className="mt-2 text-slate-600">
{lang === 'zh'
? '加入开发者社区,获取教程、博客、视频等资源。'
: 'Join the community for tutorials, blog posts, and guided videos.'}
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={config.repoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
<Github className="h-4 w-4" aria-hidden="true" />
GitHub
</Link>
<button
type="button"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
onClick={onExportPoster}
>
<MessageCircle className="h-4 w-4" aria-hidden="true" />
{lang === 'zh' ? '分享海报' : 'Share poster'}
</button>
<Link
href={config.blogUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
<Newspaper className="h-4 w-4" aria-hidden="true" />
{lang === 'zh' ? '官方博客' : 'Official blog'}
</Link>
<Link
href={config.videosUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
<PlayCircle className="h-4 w-4" aria-hidden="true" />
{lang === 'zh' ? '视频教程' : 'Video tutorials'}
</Link>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900">
{lang === 'zh' ? '扫码体验' : 'Scan to try'}
</h3>
<p className="mt-2 text-sm text-slate-600">
{lang === 'zh'
? '保存或分享二维码,适用于微信等内置浏览器。'
: 'Save or share the QR code—works in embedded browsers like WeChat.'}
</p>
<div className="mt-4 flex flex-col gap-6 sm:flex-row">
<div className="flex flex-col items-center gap-3">
<div
ref={qrRef}
className="flex h-[180px] w-[180px] items-center justify-center rounded-xl border border-slate-200 bg-white"
aria-label={lang === 'zh' ? '产品二维码' : 'Product QR code'}
/>
<span className="text-xs text-slate-500">{qrUrl}</span>
</div>
<div className="flex-1 space-y-3 text-sm text-slate-700">
<label className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">
{lang === 'zh' ? '自定义跳转链接' : 'Custom URL'}
</span>
<input
value={qrUrl}
onChange={(event) => onQrUrlChange(event.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand-light"
/>
</label>
<button
type="button"
onClick={onExportPoster}
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
<QrCode className="h-4 w-4" aria-hidden="true" />
{lang === 'zh' ? '导出海报' : 'Export poster'}
</button>
</div>
</div>
</div>
</div>
</div>
<Poster config={config} lang={lang} posterQrRef={posterQrRef} ref={posterRef} />
</section>
)
}

View File

@ -0,0 +1,111 @@
import Link from 'next/link'
import type { ProductConfig } from '@src/products/registry'
type ProductDocsProps = {
config: ProductConfig
lang: 'zh' | 'en'
}
export default function ProductDocs({ config, lang }: ProductDocsProps) {
return (
<section id="docs" aria-labelledby="docs-title" className="bg-slate-50 py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="grid gap-10 lg:grid-cols-2">
<div>
<h2 id="docs-title" className="text-3xl font-bold text-slate-900">
{lang === 'zh' ? '帮助文档' : 'Documentation'}
</h2>
<ol className="mt-4 space-y-3 text-sm text-slate-700">
<li>
1. {lang === 'zh' ? '下载并安装客户端' : 'Download and install the client'}
</li>
<li>
2. {lang === 'zh' ? '注册或登录账户' : 'Sign up or sign in'}
</li>
<li>
3. {lang === 'zh' ? '选择节点或启用自动分配' : 'Choose a node or use auto selection'}
</li>
<li>
4. {lang === 'zh' ? '开始加速并查看监控' : 'Start acceleration with live metrics'}
</li>
</ol>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={config.docsQuickstart}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
{lang === 'zh' ? '快速开始指南' : 'Quickstart'}
</Link>
<Link
href={config.docsIssues}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
GitHub
</Link>
<Link
href={config.videosUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
{lang === 'zh' ? '视频教程' : 'Video tutorials'}
</Link>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<h3 className="text-lg font-semibold text-slate-900">
{lang === 'zh' ? '文档资源' : 'Resource links'}
</h3>
<ul className="mt-4 grid gap-3 text-sm text-slate-700 sm:grid-cols-2">
<li>
<Link
href={config.docsQuickstart}
target="_blank"
rel="noreferrer"
className="block rounded-lg border border-slate-200 px-3 py-2 hover:bg-slate-50"
>
{lang === 'zh' ? '快速开始' : 'Quickstart'}
</Link>
</li>
<li>
<Link
href={config.docsApi}
target="_blank"
rel="noreferrer"
className="block rounded-lg border border-slate-200 px-3 py-2 hover:bg-slate-50"
>
API
</Link>
</li>
<li>
<Link
href={config.docsIssues}
target="_blank"
rel="noreferrer"
className="block rounded-lg border border-slate-200 px-3 py-2 hover:bg-slate-50"
>
{lang === 'zh' ? '故障排查' : 'Issues'}
</Link>
</li>
<li>
<Link
href={config.blogUrl}
target="_blank"
rel="noreferrer"
className="block rounded-lg border border-slate-200 px-3 py-2 hover:bg-slate-50"
>
{lang === 'zh' ? '官方博客' : 'Official blog'}
</Link>
</li>
</ul>
</div>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,101 @@
import Link from 'next/link'
import { Laptop, Monitor, Server } from 'lucide-react'
import type { ProductConfig } from '@src/products/registry'
type ProductDownloadProps = {
config: ProductConfig
lang: 'zh' | 'en'
}
const PLATFORMS = {
zh: [
{
key: 'windows',
title: 'Windows',
subtitle: 'Win10/11 · x64 / ARM64',
icon: Monitor,
},
{
key: 'mac',
title: 'macOS',
subtitle: 'macOS 12+ · Intel / Apple Silicon',
icon: Laptop,
},
{
key: 'linux',
title: 'Linux',
subtitle: 'Ubuntu / Debian / RHEL · x64 / ARM64',
icon: Server,
},
],
en: [
{
key: 'windows',
title: 'Windows',
subtitle: 'Windows 10/11 · x64 / ARM64',
icon: Monitor,
},
{
key: 'mac',
title: 'macOS',
subtitle: 'macOS 12+ · Intel / Apple Silicon',
icon: Laptop,
},
{
key: 'linux',
title: 'Linux',
subtitle: 'Ubuntu / Debian / RHEL · x64 / ARM64',
icon: Server,
},
],
}
export default function ProductDownload({ config, lang }: ProductDownloadProps) {
const items = PLATFORMS[lang]
return (
<section id="download" aria-labelledby="download-title" className="py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<header className="max-w-2xl">
<h2 id="download-title" className="text-3xl font-bold text-slate-900">
{lang === 'zh' ? '下载' : 'Download'}
</h2>
<p className="mt-2 text-slate-600">
{lang === 'zh'
? '支持 Windows / macOS / Linux安装前请核验校验值。'
: 'Windows, macOS, and Linux builds. Verify checksums before install.'}
</p>
</header>
<div className="mt-10 grid gap-6 md:grid-cols-3">
{items.map(({ key, title, subtitle, icon: Icon }) => (
<article key={key} className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex items-center gap-3 text-slate-900">
<Icon className="h-5 w-5 text-brand-dark" aria-hidden="true" />
<h3 className="text-lg font-semibold">{title}</h3>
</div>
<p className="mt-2 text-sm text-slate-600">{subtitle}</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link
href={config.downloadUrl}
target="_blank"
rel="noreferrer"
className="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-dark"
>
{lang === 'zh' ? '下载' : 'Get'}
</Link>
<Link
href="#docs"
scroll
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
SHA256
</Link>
</div>
</article>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,78 @@
import Link from 'next/link'
import type { ProductConfig } from '@src/products/registry'
type ProductEditionsProps = {
config: ProductConfig
lang: 'zh' | 'en'
}
const SECTION_COPY = {
zh: {
title: '版本与部署',
description: '开源可扩展,可自建或选择官方托管服务。',
actions: {
selfhost: '自建Self-Hosted',
managed: '托管Managed',
paygo: '按量计费Pay-as-you-go',
saas: '订阅 SaaS',
},
},
en: {
title: 'Editions & Deployment',
description: 'Open source and extensible—self-host or leverage managed offerings.',
actions: {
selfhost: 'Self-Hosted',
managed: 'Managed',
paygo: 'Pay-as-you-go',
saas: 'SaaS Subscription',
},
},
}
type EditionKey = keyof ProductConfig['editions']
export default function ProductEditions({ config, lang }: ProductEditionsProps) {
const copy = SECTION_COPY[lang]
const order: EditionKey[] = ['selfhost', 'managed', 'paygo', 'saas']
return (
<section id="editions" aria-labelledby="editions-title" className="bg-white py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<header className="max-w-2xl">
<h2 id="editions-title" className="text-3xl font-bold text-slate-900">
{copy.title}
</h2>
<p className="mt-2 text-slate-600">{copy.description}</p>
</header>
<div className="mt-10 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{order.map((key) => (
<article
key={key}
className="flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
>
<h3 className="text-lg font-semibold text-slate-900">
{copy.actions[key]}
</h3>
<ul className="mt-4 space-y-2 text-sm text-slate-600">
{config.editions[key].map((link) => (
<li key={link.href}>
<Link
href={link.href}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noreferrer' : undefined}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-2 font-medium text-slate-700 transition hover:bg-slate-50"
>
{link.label}
<span aria-hidden="true"></span>
</Link>
</li>
))}
</ul>
</article>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,69 @@
import { ChevronDown } from 'lucide-react'
type ProductFaqProps = {
lang: 'zh' | 'en'
}
const FAQ_CONTENT = {
zh: [
{
question: '是否完全开源?支持哪些商业化形态?',
answer: '核心代码开源,并提供自建、托管、按量与订阅 SaaS 多种形态。',
},
{
question: '是否支持多设备与多平台?',
answer: '支持 Windows / macOS / Linux多设备可同时登录与切换。',
},
{
question: '是否内置观测与限流保护?',
answer: '客户端与边缘节点具备观测、流量控制及异常防护能力。',
},
{
question: '如何参与贡献?',
answer: '欢迎在 GitHub 仓库提交 Issue / PR并加入社区讨论。',
},
],
en: [
{
question: 'Is it fully open source? What commercial models are available?',
answer: 'The core is open source with self-hosted, managed, pay-as-you-go, and SaaS plans.',
},
{
question: 'Does it support multiple devices and platforms?',
answer: 'Yes. Windows, macOS, and Linux with seamless device switching.',
},
{
question: 'Are observability and rate limiting built in?',
answer: 'Clients and edge nodes ship with telemetry, throttling, and protections.',
},
{
question: 'How can I contribute?',
answer: 'Open GitHub issues or pull requests and join the community discussions.',
},
],
}
export default function ProductFaq({ lang }: ProductFaqProps) {
const items = FAQ_CONTENT[lang]
return (
<section id="faq" aria-labelledby="faq-title" className="py-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<h2 id="faq-title" className="text-3xl font-bold text-slate-900">
FAQ
</h2>
<div className="mt-8 space-y-4">
{items.map(({ question, answer }) => (
<details key={question} className="group rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<summary className="flex cursor-pointer items-center justify-between text-left text-base font-semibold text-slate-900">
{question}
<ChevronDown className="h-5 w-5 text-slate-400 transition group-open:rotate-180" aria-hidden="true" />
</summary>
<p className="mt-3 text-sm text-slate-600">{answer}</p>
</details>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,85 @@
import { Activity, Brain, Rocket, Shield } from 'lucide-react'
type ProductFeaturesProps = {
lang: 'zh' | 'en'
}
const FEATURES = {
zh: [
{
title: '极速连接',
description: '智能就近接入、跨区域中转,降低首包延迟与抖动。',
icon: Rocket,
},
{
title: '安全加密',
description: '端到端加密与最小暴露面设计,确保数据安全。',
icon: Shield,
},
{
title: 'AI 优化',
description: '基于实时指标进行路径自适应选择,持续调优。',
icon: Brain,
},
{
title: '实时监控',
description: '内置观测、告警与审计,掌握全链路健康。',
icon: Activity,
},
],
en: [
{
title: 'Speed',
description: 'Smart ingress and inter-region hops reduce latency and jitter.',
icon: Rocket,
},
{
title: 'Security',
description: 'End-to-end encryption with minimal exposure surfaces.',
icon: Shield,
},
{
title: 'AI Optimization',
description: 'Adaptive routing powered by live telemetry and policy controls.',
icon: Brain,
},
{
title: 'Live Metrics',
description: 'Embedded observability, alerting, and auditing end to end.',
icon: Activity,
},
],
}
export default function ProductFeatures({ lang }: ProductFeaturesProps) {
const items = FEATURES[lang]
return (
<section id="features" aria-labelledby="features-title" className="py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<header className="max-w-2xl">
<h2 id="features-title" className="text-3xl font-bold text-slate-900">
{lang === 'zh' ? '核心功能' : 'Core Features'}
</h2>
<p className="mt-2 text-slate-600">
{lang === 'zh'
? '为稳定、低延迟的全球访问而生。'
: 'Built for stable, low-latency global access.'}
</p>
</header>
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{items.map(({ title, description, icon: Icon }) => (
<article
key={title}
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg"
>
<Icon className="h-6 w-6 text-brand-dark" aria-hidden="true" />
<h3 className="mt-4 text-lg font-semibold text-slate-900">{title}</h3>
<p className="mt-2 text-sm text-slate-600">{description}</p>
</article>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,87 @@
import Image from 'next/image'
import Link from 'next/link'
import { BookOpen, Download, QrCode } from 'lucide-react'
import type { ProductConfig } from '@src/products/registry'
type ProductHeroProps = {
config: ProductConfig
lang: 'zh' | 'en'
onExportPoster: () => void | Promise<void>
}
export default function ProductHero({ config, lang, onExportPoster }: ProductHeroProps) {
const tagline = lang === 'zh' ? config.tagline_zh : config.tagline_en
const primaryCta = lang === 'zh' ? '下载客户端' : 'Get the App'
const secondaryCta = lang === 'zh' ? '快速开始' : 'Quickstart'
const posterCta = lang === 'zh' ? '生成推广海报' : 'Create Poster'
const badges =
lang === 'zh'
? ['开源', '自建', '托管', '按量计费', '订阅 SaaS']
: ['Open Source', 'Self-Hosted', 'Managed', 'Pay-as-you-go', 'SaaS']
return (
<section className="relative overflow-hidden bg-gradient-to-b from-brand-surface/70 to-white" aria-labelledby="product-hero">
<div className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle,rgba(44,137,255,0.08)_1px,transparent_1px)] [background-size:24px_24px]" />
<div className="relative mx-auto flex max-w-6xl flex-col gap-12 px-4 py-16 sm:px-6 lg:flex-row lg:items-center lg:py-20">
<div className="max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-wide text-brand-dark">
{config.name}
</p>
<h1 id="product-hero" className="mt-4 text-4xl font-extrabold tracking-tight text-slate-900 sm:text-5xl">
{lang === 'zh' ? config.title : config.title_en}
</h1>
<p className="mt-5 text-lg text-slate-600">{tagline}</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link
href="#download"
scroll
className="inline-flex items-center gap-2 rounded-lg bg-brand px-5 py-3 text-sm font-semibold text-white shadow transition hover:bg-brand-dark"
>
<Download className="h-4 w-4" aria-hidden="true" />
{primaryCta}
</Link>
<Link
href="#docs"
scroll
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
>
<BookOpen className="h-4 w-4" aria-hidden="true" />
{secondaryCta}
</Link>
<button
type="button"
onClick={onExportPoster}
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
>
<QrCode className="h-4 w-4" aria-hidden="true" />
{posterCta}
</button>
</div>
<ul className="mt-6 flex flex-wrap gap-2 text-xs text-slate-600">
{badges.map((badge) => (
<li key={badge} className="rounded-full border border-slate-200 bg-white/70 px-3 py-1">
{badge}
</li>
))}
</ul>
</div>
<div className="relative flex flex-1 justify-center lg:justify-end">
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl">
<Image
src="https://images.unsplash.com/photo-1526498460520-4c246339dccb?q=80&w=1600&auto=format&fit=crop"
alt={lang === 'zh' ? `${config.name} 网络示意图` : `${config.name} network illustration`}
width={720}
height={480}
className="h-full w-full object-cover"
priority
/>
<div className="absolute bottom-4 left-4 rounded-lg border border-slate-200 bg-white/80 px-4 py-2 text-xs font-medium text-slate-600 backdrop-blur">
{lang === 'zh' ? 'AI 路由优化开启中…' : 'AI route optimization active…'}
</div>
</div>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,84 @@
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
type ProductScenariosProps = {
lang: 'zh' | 'en'
}
const SCENARIOS = {
zh: [
{
title: 'AI 模型 / DevTools 加速',
description: '保障 ChatGPT、Gemini、Claude 等模型服务顺畅访问。',
},
{
title: 'GitHub / 镜像分发',
description: '加速代码克隆、Release 下载与容器镜像同步。',
},
{
title: '跨境办公 / 媒体服务',
description: '优化海外购物、视频、音乐等站点体验。',
},
],
en: [
{
title: 'AI APIs & Dev Tools',
description: 'Keep ChatGPT, Gemini, Claude, and dependency downloads flowing.',
},
{
title: 'GitHub & Registry Sync',
description: 'Accelerate git clones, release artifacts, and container pulls.',
},
{
title: 'Cross-border Work & Media',
description: 'Improve shopping, video, and media experiences worldwide.',
},
],
}
export default function ProductScenarios({ lang }: ProductScenariosProps) {
const items = SCENARIOS[lang]
return (
<section id="scenarios" aria-labelledby="scenarios-title" className="bg-slate-50 py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 id="scenarios-title" className="text-3xl font-bold text-slate-900">
{lang === 'zh' ? '典型应用场景' : 'Typical Use Cases'}
</h2>
<p className="mt-2 text-slate-600">
{lang === 'zh'
? '聚焦开发者常用服务与日常访问,游戏等超低延迟场景不做覆盖。'
: 'Focused on developer services and daily access—not tuned for ultra-low-latency gaming.'}
</p>
</div>
<Link
href="#docs"
scroll
className="inline-flex items-center gap-2 text-sm font-semibold text-brand-dark hover:underline"
>
{lang === 'zh' ? '查看使用说明' : 'Read the guide'}
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Link>
</header>
<div className="mt-10 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{items.map(({ title, description }) => (
<article key={title} className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
<p className="mt-3 text-sm text-slate-600">{description}</p>
<Link
href="#docs"
scroll
className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-brand-dark hover:underline"
>
{lang === 'zh' ? '了解详情' : 'Learn more'}
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Link>
</article>
))}
</div>
</div>
</section>
)
}

View File

@ -12,6 +12,14 @@ const nextConfig = {
protocol: 'https',
hostname: 'dl.svc.plus',
},
{
protocol: 'https',
hostname: 'www.svc.plus',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
},
@ -51,4 +59,29 @@ const nextConfig = {
},
};
export async function redirects() {
return [
{
source: '/XStream',
destination: '/xstream',
permanent: true,
},
{
source: '/Xstream',
destination: '/xstream',
permanent: true,
},
{
source: '/XScopeHub',
destination: '/xscopehub',
permanent: true,
},
{
source: '/XCloudFlow',
destination: '/xcloudflow',
permanent: true,
},
];
}
export default nextConfig;

View File

@ -12,6 +12,9 @@
"build:static": "npm run prebuild && next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"preview": "next build && next start",
"test": "vitest run",
"test:unit": "vitest run",
"test:e2e": "playwright test"
@ -19,6 +22,7 @@
"dependencies": {
"dompurify": "^3.2.6",
"gray-matter": "^4.0.3",
"html2canvas": "^1.4.1",
"js-yaml": "^4.1.0",
"lucide-react": "^0.319.0",
"marked": "^16.1.2",
@ -53,6 +57,7 @@
"eslint-config-next": "^15.5.3",
"jsdom": "^24.0.0",
"postcss": "^8.4.32",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.3",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"tsx": "^4.7.1",

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { PRODUCT_LIST, PRODUCT_MAP, getAllSlugs } from './registry'
describe('product registry', () => {
it('exposes at least one product', () => {
expect(PRODUCT_LIST.length).toBeGreaterThan(0)
})
it('contains unique slugs', () => {
const slugs = getAllSlugs()
const unique = new Set(slugs)
expect(unique.size).toBe(slugs.length)
})
it('maps slugs to configs', () => {
for (const product of PRODUCT_LIST) {
expect(PRODUCT_MAP.get(product.slug)).toBe(product)
}
})
it('provides expected config shape', () => {
for (const product of PRODUCT_LIST) {
expect(product.slug).toMatch(/^[a-z0-9-]+$/)
expect(product.name).toBeTruthy()
expect(product.title).toBeTruthy()
expect(product.title_en).toBeTruthy()
expect(product.tagline_zh).toBeTruthy()
expect(product.tagline_en).toBeTruthy()
expect(product.ogImage).toContain('http')
expect(product.repoUrl).toContain('http')
expect(product.docsQuickstart).toContain('http')
expect(product.docsApi).toContain('http')
expect(product.docsIssues).toContain('http')
expect(product.blogUrl).toContain('http')
expect(product.videosUrl).toContain('http')
expect(product.downloadUrl).toContain('http')
}
})
})

View File

@ -0,0 +1,42 @@
import xcloudflow from './xcloudflow'
import xscopehub from './xscopehub'
import xstream from './xstream'
export type EditionLink = {
label: string
href: string
external?: boolean
}
export type Editions = {
selfhost: EditionLink[]
managed: EditionLink[]
paygo: EditionLink[]
saas: EditionLink[]
}
export type ProductConfig = {
slug: string
name: string
title: string
title_en: string
tagline_zh: string
tagline_en: string
ogImage: string
repoUrl: string
docsQuickstart: string
docsApi: string
docsIssues: string
blogUrl: string
videosUrl: string
downloadUrl: string
editions: Editions
}
export const PRODUCT_LIST: ProductConfig[] = [xstream, xscopehub, xcloudflow]
export const PRODUCT_MAP = new Map<string, ProductConfig>(
PRODUCT_LIST.map((product) => [product.slug, product])
)
export const getAllSlugs = (): string[] => PRODUCT_LIST.map((product) => product.slug)

View File

@ -0,0 +1,55 @@
import type { ProductConfig } from './registry'
const xcloudflow: ProductConfig = {
slug: 'xcloudflow',
name: 'XCloudFlow',
title: 'XCloudFlow — 多云工作流与自动化平台',
title_en: 'XCloudFlow — Multi-cloud Workflow Automation',
tagline_zh: '统一调度跨云资源,内置 AI 协作与合规审计。',
tagline_en: 'Coordinate multi-cloud workloads with AI assistance and governance built in.',
ogImage: 'https://www.svc.plus/assets/og/xcloudflow.png',
repoUrl: 'https://github.com/Cloud-Neutral/XCloudFlow',
docsQuickstart: 'https://www.svc.plus/xcloudflow/docs/quickstart',
docsApi: 'https://www.svc.plus/xcloudflow/docs/api',
docsIssues: 'https://github.com/Cloud-Neutral/XCloudFlow/issues',
blogUrl: 'https://www.svc.plus/blog/tags/xcloudflow',
videosUrl: 'https://www.svc.plus/videos/xcloudflow',
downloadUrl: 'https://www.svc.plus/xcloudflow/downloads',
editions: {
selfhost: [
{
label: 'Terraform 模块',
href: 'https://github.com/Cloud-Neutral/XCloudFlow/tree/main/deploy/terraform',
external: true,
},
{
label: '离线安装包',
href: 'https://www.svc.plus/xcloudflow/downloads',
external: true,
},
],
managed: [
{
label: '专业托管',
href: 'https://www.svc.plus/contact?product=xcloudflow',
external: true,
},
],
paygo: [
{
label: '按量计费',
href: 'https://www.svc.plus/pricing/xcloudflow',
external: true,
},
],
saas: [
{
label: '团队订阅',
href: 'https://www.svc.plus/xcloudflow/signup',
external: true,
},
],
},
}
export default xcloudflow

View File

@ -0,0 +1,55 @@
import type { ProductConfig } from './registry'
const xscopehub: ProductConfig = {
slug: 'xscopehub',
name: 'XScopeHub',
title: 'XScopeHub — 云原生可观测性控制台',
title_en: 'XScopeHub — Cloud Observability Hub',
tagline_zh: '统一指标、日志、链路追踪,一站式智能告警。',
tagline_en: 'Unified metrics, logs, and traces with intelligent alerting in one hub.',
ogImage: 'https://www.svc.plus/assets/og/xscopehub.png',
repoUrl: 'https://github.com/Cloud-Neutral/XScopeHub',
docsQuickstart: 'https://www.svc.plus/xscopehub/docs/quickstart',
docsApi: 'https://www.svc.plus/xscopehub/docs/api',
docsIssues: 'https://github.com/Cloud-Neutral/XScopeHub/issues',
blogUrl: 'https://www.svc.plus/blog/tags/xscopehub',
videosUrl: 'https://www.svc.plus/videos/xscopehub',
downloadUrl: 'https://www.svc.plus/xscopehub/downloads',
editions: {
selfhost: [
{
label: '部署包下载',
href: 'https://www.svc.plus/xscopehub/downloads',
external: true,
},
{
label: 'Helm Chart',
href: 'https://github.com/Cloud-Neutral/XScopeHub/tree/main/deploy/helm',
external: true,
},
],
managed: [
{
label: '预约演示',
href: 'https://www.svc.plus/contact?product=xscopehub',
external: true,
},
],
paygo: [
{
label: '弹性计费',
href: 'https://www.svc.plus/pricing/xscopehub',
external: true,
},
],
saas: [
{
label: '立即订阅',
href: 'https://www.svc.plus/xscopehub/signup',
external: true,
},
],
},
}
export default xscopehub

View File

@ -0,0 +1,55 @@
import type { ProductConfig } from './registry'
const xstream: ProductConfig = {
slug: 'xstream',
name: 'Xstream',
title: 'Xstream — 全球网络加速器',
title_en: 'Xstream — Global Network Accelerator',
tagline_zh: '极速连接安全加密AI 路径优化|实时监控。',
tagline_en: 'Fast connect | Secure encryption | AI path optimization | Live metrics.',
ogImage: 'https://www.svc.plus/assets/og/xstream.png',
repoUrl: 'https://github.com/Cloud-Neutral/Xstream',
docsQuickstart: 'https://github.com/Cloud-Neutral/Xstream#readme',
docsApi: 'https://github.com/Cloud-Neutral/Xstream/tree/main/docs',
docsIssues: 'https://github.com/Cloud-Neutral/Xstream/issues',
blogUrl: 'https://www.svc.plus/blog',
videosUrl: 'https://www.svc.plus/videos',
downloadUrl: 'https://github.com/Cloud-Neutral/Xstream/releases',
editions: {
selfhost: [
{
label: 'GitHub 仓库',
href: 'https://github.com/Cloud-Neutral/Xstream',
external: true,
},
{
label: '部署指南',
href: 'https://github.com/Cloud-Neutral/Xstream#deployment',
external: true,
},
],
managed: [
{
label: '联系咨询',
href: 'https://www.svc.plus/contact',
external: true,
},
],
paygo: [
{
label: '价格与账单',
href: 'https://www.svc.plus/pricing',
external: true,
},
],
saas: [
{
label: '注册与订阅',
href: 'https://www.svc.plus/xstream/signup',
external: true,
},
],
},
}
export default xstream

View File

@ -30,7 +30,7 @@
"@templates/*": ["src/templates/*"],
"@src/*": ["src/*"]
},
"types": ["node"],
"types": ["node", "vitest/globals", "@testing-library/jest-dom"],
"plugins": [{ "name": "next" }]
},
"include": [

File diff suppressed because it is too large Load Diff