feat(web): 官网 ui_kits/website → Astro 纯静态 SSG 迁移 (tsk_acMYQ-Z-EIF_)
新建 web/website/(Astro 零 SSR): - 组件迁移:交互块保留 .jsx 经 @astrojs/react(Header/AnnouncementBar/SignupForm/PricingPlans),纯展示块转 .astro 去运行时 JS;像素以原型为基准。 - 令牌同源:build-tokens.mjs 从 design/colors_and_type.css 生成 tokens.gen.css,仅剔除第三方 Google Fonts @import,数值不改。 - 字体自托管:@fontsource(Sora/Manrope/Noto Sans SC/JetBrains Mono),无第三方 CDN。 - 图标:Lucide 构建期内联 SVG(替代 unpkg CDN),零运行时、零 CDN。 - i18n:/(zh)与 /en/(en)双路由单显,语言切换组件;文案沿用 ui_kits 脱敏文案。 - 安全:public/_headers 严格 CSP(全 self + 自托管资源 + 内联片段 sha256,无 unsafe-inline)+ HSTS;无支付表单。 - CI:.gitea/workflows/website.yml 构建(红线扫描+lint+CSP 哈希)→ 同时发布 Cloudflare Pages 主站与镜像。 - 灾备:README 写明干净环境 npm ci && npm run build 可直接部署到任意静态托管;dist 指纹确定性,主站镜像一致。 测试:npm run lint(0 error)/ npm test(build+CSP 哈希注入+红线扫描 0 命中)/ 两次干净构建 dist 指纹一致。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AnnouncementBar.jsx — 顶部公告条(React island)。迁自原型 .ann + site.js __closeAnn。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Gift, X } from 'lucide-react';
|
||||
|
||||
export default function AnnouncementBar({ text, cta }) {
|
||||
const [closed, setClosed] = useState(false);
|
||||
if (closed) return null;
|
||||
return (
|
||||
<div class="ann" id="ann">
|
||||
<div class="wrap ann-in">
|
||||
<Gift />
|
||||
<span>{text}</span>
|
||||
<a href="#signup">{cta}</a>
|
||||
<button class="ann-x" onClick={() => setClosed(true)} aria-label="close">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const posts = [
|
||||
{ cover: '', tag: 'blog.1tag', date: '2026-05-28', t: 'blog.1t', d: 'blog.1d' },
|
||||
{ cover: 'c2', tag: 'blog.2tag', date: '2026-05-12', t: 'blog.2t', d: 'blog.2d' },
|
||||
{ cover: 'c3', tag: 'blog.3tag', date: '2026-04-30', t: 'blog.3t', d: 'blog.3d' },
|
||||
];
|
||||
---
|
||||
<section id="blog" class="sec-tan">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('blog.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('blog.h')}</h2>
|
||||
<p class="sub-sec">{t('blog.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="blog-grid">
|
||||
{posts.map((p) => (
|
||||
<a class="post">
|
||||
<div class={p.cover ? `cover ${p.cover}` : 'cover'}>
|
||||
<span class="tag">{t(p.tag)}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="date">{p.date}</div>
|
||||
<h3>{t(p.t)}</h3>
|
||||
<p>{t(p.d)}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
/**
|
||||
* Brand.astro — 品牌母题「行走穿山甲」内联 SVG(铁律 11:直接用既有矢量,不另绘)。
|
||||
* 逐字取自 design/ui_kits/website/index.html 的内联 mark。
|
||||
* variant: header(clay 实心 + 浅色鳞纹)/ footer(浅色实心 + clay 圆点)。
|
||||
*/
|
||||
interface Props {
|
||||
variant?: 'header' | 'footer';
|
||||
size?: number;
|
||||
}
|
||||
const { variant = 'header', size = 30 } = Astro.props;
|
||||
---
|
||||
{variant === 'header' ? (
|
||||
<svg width={size} height={size} viewBox="0 0 96 96" fill="none" aria-hidden="true">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#B96A3D"></path>
|
||||
<g stroke="#FAF3ED" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.85">
|
||||
<path d="M26 50 Q34 43 42 50"></path>
|
||||
<path d="M36 48 Q44 41 52 48"></path>
|
||||
<path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width={size} height={size} viewBox="0 0 96 96" fill="none" aria-hidden="true">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#F4EFE8"></path>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#9E5630"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
---
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="cta-band">
|
||||
<h2>{t('cta.h')}</h2>
|
||||
<p>{t('cta.p')}</p>
|
||||
<div class="cta-actions">
|
||||
<a class="btn btn-white btn-lg" href="#signup"><span>{t('cta.btn2')}</span></a>
|
||||
<a class="btn btn-lg cta-ghost" href="#download"><Icon name="download" /><span>{t('cta.btn')}</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const docs = [
|
||||
{ icon: 'rocket', t: 'docs.1t', d: 'docs.1d' },
|
||||
{ icon: 'circle-help', t: 'docs.2t', d: 'docs.2d' },
|
||||
{ icon: 'shield-check', t: 'docs.3t', d: 'docs.3d' },
|
||||
{ icon: 'lock', t: 'docs.4t', d: 'docs.4d' },
|
||||
];
|
||||
---
|
||||
<section id="docs">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('docs.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('docs.h')}</h2>
|
||||
<p class="sub-sec">{t('docs.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="docs-grid">
|
||||
{docs.map((d) => (
|
||||
<a class="doc">
|
||||
<div class="ico"><Icon name={d.icon} /></div>
|
||||
<h3>{t(d.t)}</h3>
|
||||
<p>{t(d.d)}</p>
|
||||
<span class="ln">{t('docs.read')}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const plats = [
|
||||
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
|
||||
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+' },
|
||||
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
|
||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11' },
|
||||
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
|
||||
];
|
||||
---
|
||||
<section id="download" class="sec-tan">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('dl.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('dl.h')}</h2>
|
||||
<p class="sub-sec">{t('dl.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="dl-grid">
|
||||
{plats.map((p) => (
|
||||
<div class="dl">
|
||||
<div class="ico"><Icon name={p.icon} /></div>
|
||||
<div class="pn">{p.name}</div>
|
||||
<div class="pv">{p.ver}</div>
|
||||
<a class="gb"><Icon name="download" /><span>{t('dl.get')}</span></a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const items = [
|
||||
{ icon: 'power', t: 'feat.1t', d: 'feat.1d' },
|
||||
{ icon: 'zap', t: 'feat.2t', d: 'feat.2d' },
|
||||
{ icon: 'globe', t: 'feat.3t', d: 'feat.3d' },
|
||||
{ icon: 'eye-off', t: 'feat.4t', d: 'feat.4d' },
|
||||
{ icon: 'shield', t: 'feat.5t', d: 'feat.5d' },
|
||||
{ icon: 'monitor-smartphone', t: 'feat.6t', d: 'feat.6d' },
|
||||
];
|
||||
---
|
||||
<section id="product" class="sec-tan">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('feat.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('feat.h')}</h2>
|
||||
<p class="sub-sec">{t('feat.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="feat-grid">
|
||||
{items.map((it) => (
|
||||
<div class="feat">
|
||||
<div class="ico"><Icon name={it.icon} /></div>
|
||||
<h3>{t(it.t)}</h3>
|
||||
<p>{t(it.d)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
import Brand from './Brand.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
---
|
||||
<footer class="ftr">
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="nm">
|
||||
<Brand variant="footer" size={28} />
|
||||
穿山甲
|
||||
</div>
|
||||
<p class="tag">{t('ft.tag')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{t('ft.product')}</h4>
|
||||
<ul>
|
||||
<li><a href="#product">{t('ft.features')}</a></li>
|
||||
<li><a href="#pricing">{t('ft.pricing')}</a></li>
|
||||
<li><a href="#download">{t('ft.download')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{t('ft.resources')}</h4>
|
||||
<ul>
|
||||
<li><a href="#docs">{t('ft.docs')}</a></li>
|
||||
<li><a href="#blog">{t('ft.blog')}</a></li>
|
||||
<li><a href="#docs">{t('ft.faq')}</a></li>
|
||||
<li><a href="#docs">{t('ft.privacy')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{t('ft.contact')}</h4>
|
||||
<ul>
|
||||
<li><a class="mono">shop.pangolin.vpn</a></li>
|
||||
<li><a class="mono">Telegram @PangolinVPN_bot</a></li>
|
||||
<li><a class="mono">LINE @pangolinvpn</a></li>
|
||||
<li><a class="mono">support@pangolin.vpn</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bot">
|
||||
<span>{t('ft.copy')}</span>
|
||||
<span>{t('ft.madenote')}</span>
|
||||
<span>{t('ft.hours')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Header.jsx — 交互式顶栏(React island,@astrojs/react 渲染 + 水合)。
|
||||
* 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。
|
||||
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Menu } from 'lucide-react';
|
||||
|
||||
function Mark() {
|
||||
return (
|
||||
<svg width="30" height="30" viewBox="0 0 96 96" fill="none" aria-hidden="true">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path
|
||||
d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z"
|
||||
fill="#B96A3D"
|
||||
/>
|
||||
<g stroke="#FAF3ED" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" fill="none" opacity="0.85">
|
||||
<path d="M26 50 Q34 43 42 50" />
|
||||
<path d="M36 48 Q44 41 52 48" />
|
||||
<path d="M46 48 Q54 41 62 49" />
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header({ lang = 'zh', t = {} }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const nav = [
|
||||
['#product', t.product],
|
||||
['#pricing', t.pricing],
|
||||
['#download', t.download],
|
||||
['#docs', t.docs],
|
||||
['#blog', t.blog],
|
||||
];
|
||||
|
||||
return (
|
||||
<header class={`hdr${scrolled ? ' scrolled' : ''}`}>
|
||||
<div class="wrap row">
|
||||
<a class="brand" href="#top">
|
||||
<Mark />
|
||||
<span class="nm">穿山甲</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
{nav.map(([href, label]) => (
|
||||
<a key={href} href={href}>{label}</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="right">
|
||||
<div class="langseg">
|
||||
<a data-lang="zh" class={lang === 'zh' ? 'on' : undefined} href="/">中文</a>
|
||||
<a data-lang="en" class={lang === 'en' ? 'on' : undefined} href="/en/">EN</a>
|
||||
</div>
|
||||
<span class="linklogin">{t.login}</span>
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<Download />
|
||||
<span>{t.get}</span>
|
||||
</a>
|
||||
<button class="menu-btn" onClick={() => setOpen((v) => !v)} aria-label="menu" aria-expanded={open}>
|
||||
<Menu />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`mnav${open ? ' open' : ''}`} id="mnav">
|
||||
{nav.map(([href, label]) => (
|
||||
<a key={href} href={href} onClick={() => setOpen(false)}>{label}</a>
|
||||
))}
|
||||
<a class="btn btn-primary" href="#signup" onClick={() => setOpen(false)}>
|
||||
<span>{t.suBtn}</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import SignupForm from './SignupForm.jsx';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
---
|
||||
<section class="hero">
|
||||
<div class="wrap grid">
|
||||
<div>
|
||||
<div class="eyebrow">{t('hero.eyebrow')}</div>
|
||||
<h1 class="h1-preline">{t('hero.h1')}</h1>
|
||||
<p class="lede">{t('hero.lede')}</p>
|
||||
|
||||
<SignupForm
|
||||
client:idle
|
||||
ph={t('su.ph')}
|
||||
btn={t('su.btn')}
|
||||
ok={t('su.ok')}
|
||||
hint={t('su.hint')}
|
||||
dl={t('su.dl')}
|
||||
/>
|
||||
|
||||
<div class="trust">
|
||||
<span><Icon name="check" /><span>{t('hero.t1')}</span></span>
|
||||
<span><Icon name="check" /><span>{t('hero.t2')}</span></span>
|
||||
<span><Icon name="check" /><span>{t('hero.t3')}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="orb-wrap">
|
||||
<div class="orb-card">
|
||||
<div class="orb">
|
||||
<svg class="ring" viewBox="0 0 184 184"><circle cx="92" cy="92" r="85" fill="none" stroke="rgba(255,255,255,.85)" stroke-width="4" stroke-linecap="round"></circle></svg>
|
||||
<Icon name="shield-check" class="ic" />
|
||||
<span class="tm">02:14:08</span>
|
||||
<span class="sf">{t('orb.cap')}</span>
|
||||
</div>
|
||||
<div class="orb-meta">
|
||||
<div class="m"><div class="k"><Icon name="arrow-down" /><span>{t('orb.down')}</span></div><div class="v">86.4<span class="orb-unit"> Mb/s</span></div></div>
|
||||
<div class="m"><div class="k"><Icon name="arrow-up" /><span>{t('orb.up')}</span></div><div class="v">12.1<span class="orb-unit"> Mb/s</span></div></div>
|
||||
</div>
|
||||
<div class="orb-node">
|
||||
<div class="cc">HK</div>
|
||||
<div class="orb-node-grow"><div class="nn">{t('orb.node')}</div><div class="ns">{t('orb.nodesub')}</div></div>
|
||||
<Icon name="chevron-right" class="orb-node-chev" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap strip">
|
||||
<span>{t('strip.txt')}</span>
|
||||
<span><Icon name="smartphone" />iOS</span>
|
||||
<span><Icon name="smartphone" />Android</span>
|
||||
<span><Icon name="laptop" />macOS</span>
|
||||
<span><Icon name="monitor" />Windows</span>
|
||||
<span><Icon name="terminal" />Linux</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
/**
|
||||
* Icon.astro — 把原型里的 `<i data-lucide="name">` 机械替换为「构建期内联 SVG」。
|
||||
* 使用 lucide-react,但**不**水合(无 client: 指令)→ Astro 在构建期渲染成静态 <svg>,
|
||||
* 零运行时 JS、零第三方 CDN(原型用的是 unpkg.com 的 lucide,已按任务要求去除)。
|
||||
* 尺寸/描边颜色沿用 website.css 里对 `svg` 的选择器控制,故此处不强加尺寸。
|
||||
*/
|
||||
import * as Lucide from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
class?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const { name, class: className, ...rest } = Astro.props;
|
||||
|
||||
// kebab-case → PascalCase(lucide-react 导出名)
|
||||
const pascal = name
|
||||
.split('-')
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
|
||||
// 个别图标的兜底别名(lucide 改名历史)。
|
||||
const ALIASES: Record<string, string> = {
|
||||
CheckCircle: 'CircleCheckBig',
|
||||
CircleHelp: 'CircleHelp',
|
||||
};
|
||||
|
||||
const lib = Lucide as unknown as Record<string, any>;
|
||||
const Comp = lib[pascal] ?? lib[ALIASES[pascal] ?? ''] ?? null;
|
||||
|
||||
if (!Comp) {
|
||||
throw new Error(`[Icon] 未知 lucide 图标: "${name}" (尝试 ${pascal})`);
|
||||
}
|
||||
---
|
||||
<Comp class={['lucide', className].filter(Boolean).join(' ')} {...rest} />
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import PricingPlans from './PricingPlans.jsx';
|
||||
import { PRICES, type T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const plansData = {
|
||||
monthly: t('price.monthly'),
|
||||
yearly: t('price.yearly'),
|
||||
save: t('price.save'),
|
||||
permo: t('price.permo'),
|
||||
popular: t('price.popular'),
|
||||
plans: [
|
||||
{
|
||||
key: 'free',
|
||||
name: t('price.free'),
|
||||
price: PRICES.free,
|
||||
desc: t('price.freedesc'),
|
||||
feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')],
|
||||
cta: t('price.cta_free'),
|
||||
ctaClass: 'pcta-out',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: 'pro',
|
||||
name: t('price.pro'),
|
||||
price: PRICES.pro,
|
||||
desc: t('price.prodesc'),
|
||||
feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')],
|
||||
cta: t('price.cta_pro'),
|
||||
ctaClass: 'pcta-white',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
name: t('price.team'),
|
||||
price: PRICES.team,
|
||||
desc: t('price.teamdesc'),
|
||||
feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')],
|
||||
cta: t('price.cta_team'),
|
||||
ctaClass: 'pcta-fill',
|
||||
highlight: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 功能对比表行:[名称键, free, pro, team];值可为字符串或 'check' / 'dash'。
|
||||
const rows = [
|
||||
{ label: 'cmp.locations', free: '1', pro: '80+', team: '80+' },
|
||||
{ label: 'cmp.data', free: t('cmp.daily'), pro: t('cmp.unlimited'), team: t('cmp.unlimited') },
|
||||
{ label: 'cmp.time', free: t('cmp.time_free'), pro: t('cmp.unlimited'), team: t('cmp.unlimited') },
|
||||
{ label: 'cmp.devices', free: '1', pro: '5', team: '10' },
|
||||
{ label: 'cmp.speed', free: 'dash', pro: 'check', team: 'check' },
|
||||
{ label: 'cmp.stream', free: 'dash', pro: 'check', team: 'check' },
|
||||
{ label: 'cmp.kill', free: 'dash', pro: 'check', team: 'check' },
|
||||
{ label: 'cmp.support', free: t('cmp.basic'), pro: t('cmp.basic'), team: t('cmp.priority') },
|
||||
];
|
||||
|
||||
function cell(v: string) {
|
||||
return v === 'check' ? 'yes' : v === 'dash' ? 'no' : 'tx';
|
||||
}
|
||||
---
|
||||
<section id="pricing">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('price.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('price.h')}</h2>
|
||||
<p class="sub-sec">{t('price.sub')}</p>
|
||||
</div>
|
||||
|
||||
<PricingPlans client:visible data={plansData} />
|
||||
|
||||
<div class="wrap">
|
||||
<!-- payment note -->
|
||||
<div class="pay-note">
|
||||
<Icon name="shield-check" />
|
||||
<div>
|
||||
<b>{t('pay.title')}</b><br>
|
||||
<span>{t('pay.body')}</span>
|
||||
<div class="chips">
|
||||
<span class="chip"><Icon name="shopping-bag" /><span>{t('pay.store')}</span></span>
|
||||
<span class="chip"><Icon name="credit-card" /><span>{t('pay.usdt')}</span></span>
|
||||
<span class="chip"><Icon name="send" />Telegram @PangolinVPN_bot</span>
|
||||
<span class="chip"><Icon name="message-circle" />LINE @pangolinvpn</span>
|
||||
<span class="chip"><Icon name="mail" />buy@pangolin.vpn</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- comparison table -->
|
||||
<h3 class="cmp-h">{t('cmp.h')}</h3>
|
||||
<div class="cmp">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('cmp.feature')}</th>
|
||||
<th>{t('price.free')}</th>
|
||||
<th>{t('price.pro')}</th>
|
||||
<th>{t('price.team')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr>
|
||||
<td>{t(r.label)}</td>
|
||||
{[r.free, r.pro, r.team].map((v) => (
|
||||
<td class={cell(v)}>
|
||||
{v === 'check' ? <Icon name="check" /> : v === 'dash' ? '—' : v}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PricingPlans.jsx — 月/年切换 + 三档套餐卡(React island)。
|
||||
* 迁自原型 .price-toggle + .price-grid + site.js __setCycle。
|
||||
* 铁律 10:CTA 为「获取激活码 / 免费下载」,不含任何支付表单;价格对齐 design/CLAUDE.md §7。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
export default function PricingPlans({ data }) {
|
||||
const [cycle, setCycle] = useState('monthly');
|
||||
const i = cycle === 'monthly' ? 0 : 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="wrap center">
|
||||
<div class="price-toggle">
|
||||
<button
|
||||
class={cycle === 'monthly' ? 'on' : undefined}
|
||||
data-cycle="monthly"
|
||||
onClick={() => setCycle('monthly')}
|
||||
>
|
||||
{data.monthly}
|
||||
</button>
|
||||
<button
|
||||
class={cycle === 'yearly' ? 'on' : undefined}
|
||||
data-cycle="yearly"
|
||||
onClick={() => setCycle('yearly')}
|
||||
>
|
||||
<span>{data.yearly}</span>
|
||||
<span class="save">{data.save}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="price-grid">
|
||||
{data.plans.map((p) => (
|
||||
<div key={p.key} class={p.highlight ? 'plan feat-plan' : 'plan'}>
|
||||
{p.highlight && <div class="pop">{data.popular}</div>}
|
||||
<div class="pname">{p.name}</div>
|
||||
<div class="price">
|
||||
{p.price[i]}
|
||||
<small>{data.permo}</small>
|
||||
</div>
|
||||
<div class="pdesc">{p.desc}</div>
|
||||
<ul class="feats">
|
||||
{p.feats.map((f, fi) => (
|
||||
<li key={fi}>
|
||||
<Check />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button class={`pcta ${p.ctaClass}`}>{p.cta}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* SignupForm.jsx — Hero 内联邮箱注册(演示,React island)。迁自原型 .signup + site.js __signup。
|
||||
* 注意(铁律 10):这不是支付表单;仅演示「发送验证码」交互,真实实现由后端 auth 接管。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Mail, CheckCircle, Download } from 'lucide-react';
|
||||
|
||||
export default function SignupForm({ ph, btn, ok, hint, dl }) {
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const email = e.currentTarget.elements['su-email'];
|
||||
if (!/\S+@\S+\.\S+/.test(email.value)) {
|
||||
email.focus();
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<form class="signup" id="signup" onSubmit={onSubmit}>
|
||||
<div class="su-row">
|
||||
<div class="su-field">
|
||||
<Mail />
|
||||
<input type="email" id="su-email" name="su-email" placeholder={ph} required />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg su-btn" type="submit">
|
||||
<span>{btn}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="su-ok" id="su-ok" hidden={!done}>
|
||||
<CheckCircle />
|
||||
<span>{ok}</span>
|
||||
</div>
|
||||
<div class="su-hint">
|
||||
<span>{hint}</span>
|
||||
<a class="su-dl" href="#download">
|
||||
<Download />
|
||||
<span>{dl}</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const cards = [
|
||||
{ icon: 'gift', t: 'why.1t', d: 'why.1d' },
|
||||
{ icon: 'monitor-smartphone', t: 'why.2t', d: 'why.2d' },
|
||||
{ icon: 'badge-check', t: 'why.3t', d: 'why.3d' },
|
||||
];
|
||||
---
|
||||
<section id="why-signup">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('why.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('why.h')}</h2>
|
||||
<p class="sub-sec">{t('why.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="why-grid">
|
||||
{cards.map((c) => (
|
||||
<div class="why">
|
||||
<div class="ico"><Icon name={c.icon} /></div>
|
||||
<h3>{t(c.t)}</h3>
|
||||
<p>{t(c.d)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step"><span class="n">1</span><Icon name="mail" /><span class="t">{t('why.s1')}</span></div>
|
||||
<span class="step-arr"><Icon name="arrow-right" /></span>
|
||||
<div class="step"><span class="n">2</span><Icon name="shield-check" /><span class="t">{t('why.s2')}</span></div>
|
||||
<span class="step-arr"><Icon name="arrow-right" /></span>
|
||||
<div class="step"><span class="n">3</span><Icon name="power" /><span class="t">{t('why.s3')}</span></div>
|
||||
</div>
|
||||
<div class="why-cta">
|
||||
<a class="btn btn-primary btn-lg" href="#signup"><span>{t('why.cta')}</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
Reference in New Issue
Block a user