feat(web): rebuild web with Astro 6 + React 19 + Tailwind 4

Replace static HTML website with Astro SSG framework:
- Astro 6 + React 19 (client islands) + Tailwind CSS 4 + shadcn/ui
- i18n: zh/zh_Hant/en with URL prefix routing
- Pages: Landing, Features, Pricing, About, Privacy, Terms (3 locales)
- Responsive full-width layout with scroll reveal animations
- Cyber gradient theme with particle effects inspired by Kimi
- Features page: alternating layout with hexagram illustrations
- Legal pages: markdown rendering with side info card
- Language switcher preserves current page path
- Assets shared via symlinks to web/design/assets/ (no duplication)

Tech decisions documented in .trellis/spec/web/index.md
Task: .trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui
This commit is contained in:
zl-q
2026-05-09 12:11:10 +08:00
parent 04b493ed09
commit c12320cb79
72 changed files with 23855 additions and 828 deletions
+122
View File
@@ -0,0 +1,122 @@
---
import { t, localePath, type Locale } from '../i18n/utils';
import fs from 'node:fs';
import path from 'node:path';
import { marked } from 'marked';
interface Props {
locale: Locale;
docType: 'privacy_policy' | 'terms_of_service' | 'about_us';
}
const { locale, docType } = Astro.props;
const footer = t(locale, 'footer');
const about = t(locale, 'about');
const titleMap: Record<string, Record<Locale, string>> = {
privacy_policy: { zh: '隐私政策', zh_Hant: '隱私政策', en: 'Privacy Policy' },
terms_of_service: { zh: '服务条款', zh_Hant: '服務條款', en: 'Terms of Service' },
};
const subtitleMap: Record<string, Record<Locale, string>> = {
privacy_policy: { zh: '最后更新日期:2026年4月27日', zh_Hant: '最後更新日期:2026年4月27日', en: 'Last updated: April 27, 2026' },
terms_of_service: { zh: '最后更新日期:2026年4月27日', zh_Hant: '最後更新日期:2026年4月27日', en: 'Last updated: April 27, 2026' },
};
const title = titleMap[docType]?.[locale] ?? '';
const subtitle = subtitleMap[docType]?.[locale] ?? '';
const filePath = path.resolve('public/legal', locale, `${docType}.md`);
let raw = '';
try {
raw = fs.readFileSync(filePath, 'utf-8');
raw = raw.replace(/^#\s+.+\n*/m, '');
} catch {
raw = `Content not available.`;
}
const content = await marked(raw);
const sideInfo: Record<string, Record<Locale, { type: string; date: string; law?: string; email: string }>> = {
privacy_policy: {
zh: { type: '隐私政策', date: '2026年4月27日', email: 'ann@xunmee.com' },
zh_Hant: { type: '隱私政策', date: '2026年4月27日', email: 'ann@xunmee.com' },
en: { type: 'Privacy Policy', date: 'April 27, 2026', email: 'ann@xunmee.com' },
},
terms_of_service: {
zh: { type: '服务条款', date: '2026年4月27日', law: '美国加利福尼亚州法律', email: 'ann@xunmee.com' },
zh_Hant: { type: '服務條款', date: '2026年4月27日', law: '美國加利福尼亞州法律', email: 'ann@xunmee.com' },
en: { type: 'Terms of Service', date: 'April 27, 2026', law: 'California, USA', email: 'ann@xunmee.com' },
},
};
const info = sideInfo[docType]?.[locale];
const isActive = (linkType: string) => linkType === docType;
---
<!-- Header -->
<section class="w-full bg-gradient-to-b from-white to-violet-50 py-16 md:py-20 px-6 md:px-20 text-center">
<h1 class="reveal text-slate-900 text-4xl md:text-[48px] font-extrabold">{title}</h1>
<p class="reveal stagger-1 text-slate-500 text-lg mt-4">{subtitle}</p>
</section>
<!-- Content: Left article + Right info card -->
<section class="w-full py-16 md:py-20 px-6 md:px-20 bg-white">
<div class="max-w-6xl mx-auto flex flex-col md:flex-row gap-16">
<!-- Article -->
<div class="reveal flex-1 prose prose-slate max-w-none
[&_h2]:text-[28px] [&_h2]:font-bold [&_h2]:text-slate-900 [&_h2]:mt-8 [&_h2]:mb-2
[&_h3]:text-2xl [&_h3]:font-bold [&_h3]:text-slate-900 [&_h3]:mt-6 [&_h3]:mb-2
[&_p]:text-slate-500 [&_p]:text-base [&_p]:leading-loose
[&_strong]:text-slate-700 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:text-slate-500 [&_li]:leading-loose
[&_a]:text-violet-600 [&_hr]:border-slate-200 [&_hr]:my-6">
<Fragment set:html={content} />
</div>
<!-- Side card -->
{info && (
<div class="reveal-right w-full md:w-[320px] bg-slate-50 border border-slate-200 rounded-2xl p-8 flex flex-col gap-6 shrink-0 self-start sticky top-28">
<h3 class="text-slate-900 text-xl font-bold">
{locale === 'en' ? 'Document Info' : '文档信息'}
</h3>
<div class="h-px bg-slate-200"></div>
<div>
<p class="text-slate-400 text-sm font-semibold">{locale === 'en' ? 'Type' : '文档类型'}</p>
<p class="text-slate-600 text-[15px]">{info.type}</p>
</div>
<div>
<p class="text-slate-400 text-sm font-semibold">{locale === 'en' ? 'Last Updated' : '最后更新'}</p>
<p class="text-slate-600 text-[15px]">{info.date}</p>
</div>
{info.law && (
<div>
<p class="text-slate-400 text-sm font-semibold">{locale === 'en' ? 'Governing Law' : '适用法律'}</p>
<p class="text-slate-600 text-[15px]">{info.law}</p>
</div>
)}
<div>
<p class="text-slate-400 text-sm font-semibold">{locale === 'en' ? 'Contact' : '联系邮箱'}</p>
<p class="text-slate-600 text-[15px]">{info.email}</p>
</div>
</div>
)}
</div>
</section>
<!-- Warning -->
<section class="w-full bg-amber-50 py-20 px-6 md:px-20">
<div class="reveal max-w-[800px] mx-auto text-center flex flex-col gap-5">
<h3 class="text-amber-600 text-2xl font-bold">{about.warningTitle}</h3>
<p class="text-amber-700 text-[15px] leading-loose">{about.warningBody}</p>
</div>
</section>
<!-- Legal Links -->
<section class="w-full bg-slate-50 py-12 px-6 md:px-20">
<div class="reveal max-w-[600px] mx-auto text-center flex flex-col gap-5">
<h3 class="text-slate-900 text-xl font-bold">{about.legalTitle}</h3>
<div class="flex justify-center gap-8">
<a href={localePath(locale, '/privacy')} class={`text-[15px] hover:underline ${isActive('privacy_policy') ? 'text-violet-600 font-semibold' : 'text-slate-500'}`}>{footer.col3Link1}</a>
<a href={localePath(locale, '/terms')} class={`text-[15px] hover:underline ${isActive('terms_of_service') ? 'text-violet-600 font-semibold' : 'text-slate-500'}`}>{footer.col3Link2}</a>
</div>
</div>
</section>