feat(website): 邀请落地页 /i/<code> —— 显码+复制+奖励说明+下载引导(CF Pages _redirects 重写)

App 分享的邀请链接 /i/<8位邀请码> 此前回落主页;CF Pages 静态托管加 _redirects
把 /i/* 统一重写到 /i/ 的静态产物,邀请码本身由 InviteCard.jsx 在浏览器端从
location.pathname 解析并按 server/internal/reward/service.go 的字母表校验格式。
奖励文案对齐客户端 strings_zh/en.dart 的 inviteReward* 措辞;因邀请码只在运行时
可知,该页正文用 .langseg 胶囊做 zh/en 单显运行时切换,Header/Footer 仍走全站
i18n 惯例整页单显中文;下载按钮指向主页 #download 锚点。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
wangjia
2026-07-13 12:04:45 +08:00
parent 2c94b53a9e
commit 254db4f40d
4 changed files with 399 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Cloudflare Pages 重写规则。
# 邀请落地页:App 分享链接固定形如 /i/<8位邀请码>CF Pages 静态托管无路由参数,
# 用重写把任意邀请码路径统一指到 /i/ 的静态产物,邀请码本身由页面 JS 从
# location.pathname 解析(见 src/components/InviteCard.jsx)。
/i/* /i/ 200
+173
View File
@@ -0,0 +1,173 @@
/**
* InviteCard.jsx — 邀请落地页正文(React island/i/<code> 唯一动态内容)。
*
* 这条路由本质是运行时的:CF Pages 用 `_redirects` 把 `/i/*` 统一重写到静态产物
* `/i/index.html`,邀请码只存在于原始 URL 路径里,构建期完全不知道,所以只能在
* 浏览器里从 `location.pathname` 取。也正因如此,本组件对「双语」的处理**不**走
* 全站 `i18n/strings.ts` 的「构建期按路由单显」模式(那个模式要求语言在构建期已知),
* 而是在这一个组件内嵌 zh/en 两份文案、用 `.langseg`website.css 里现成的双态胶囊
* 开关,此前只有原型用过、构建期路由化后闲置)做「单显 + 切换」:任意时刻只渲染
* 一种语言的文本,不并排堆叠,语义上与全站单显原则一致,只是切换时机在运行时。
* Header/Footer 仍按全站惯例整页单显中文(面向主力市场:中国大陆用户)。
*/
import { useEffect, useMemo, useState } from 'react';
import { Copy, Check, Download, UserPlus, Gift, ArrowRight } from 'lucide-react';
// 邀请码字母表:A-Z 去 O/I2-9 去 0/1(与 server/internal/reward/service.go
// 的 inviteAlphabet 一致),固定 8 位。仅做「像不像邀请码」的格式校验,不联网核验。
const CODE_RE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/;
function extractCode(pathname) {
const m = /\/i\/([^/]+)/i.exec(pathname || '');
if (!m) return null;
let raw = '';
try {
raw = decodeURIComponent(m[1]);
} catch {
raw = m[1];
}
raw = raw.trim().toUpperCase();
return CODE_RE.test(raw) ? raw : null;
}
const COPY = {
zh: {
eyebrow: '邀请',
heroWithCode: '你的朋友邀请你使用穿山甲',
heroNoCode: '一起用穿山甲,网络更顺滑',
codeLabel: '邀请码',
codeFallback: '通用邀请',
copy: '复制邀请码',
copied: '已复制',
rewardsTitle: '奖励规则',
reward1: '好友注册 · 双方各得 3 天',
reward2: '好友首购 · 双方各得 7 天',
stepsTitle: '如何领取',
step1: '下载安装穿山甲',
step2: '注册时在「邀请码(选填)」填入此码',
step3: '双方到账,天数自动加到账户',
dl: '免费下载',
},
en: {
eyebrow: 'Invite',
heroWithCode: 'A friend invited you to try Pangolin',
heroNoCode: 'Try Pangolin — smoother, faster connections',
codeLabel: 'Invite code',
codeFallback: 'General invite',
copy: 'Copy invite code',
copied: 'Copied',
rewardsTitle: 'Rewards',
reward1: 'Friend signs up · +3 days each',
reward2: 'Friends first purchase · +7 days each',
stepsTitle: 'How to claim',
step1: 'Download and install Pangolin',
step2: 'Enter this code under “Invite code (optional)” at sign-up',
step3: 'Days are credited to both accounts automatically',
dl: 'Download free',
},
};
export default function InviteCard() {
const [code, setCode] = useState(null);
const [lang, setLang] = useState('zh');
const [copied, setCopied] = useState(false);
useEffect(() => {
setCode(extractCode(window.location.pathname));
}, []);
useEffect(() => {
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
}, [lang]);
const t = COPY[lang];
const dlHref = useMemo(() => `${lang === 'zh' ? '/zh/' : '/'}#download`, [lang]);
async function onCopy() {
if (!code) return;
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// 剪贴板权限失败时静默,用户仍可长按/选中手动复制。
}
}
return (
<section class="invite-page">
<div class="wrap">
<div class="invite-card">
<div class="langseg invite-langseg" role="group" aria-label="Language">
<button type="button" class={lang === 'zh' ? 'on' : undefined} onClick={() => setLang('zh')}>
中文
</button>
<button type="button" class={lang === 'en' ? 'on' : undefined} onClick={() => setLang('en')}>
EN
</button>
</div>
<div class="eyebrow center">{t.eyebrow}</div>
<h1 class="h-sec center invite-hero">{code ? t.heroWithCode : t.heroNoCode}</h1>
<div class="invite-code-box">
<span class="invite-code-label">{t.codeLabel}</span>
{code ? (
<span class="invite-code-value">{code}</span>
) : (
<span class="invite-code-value invite-code-value--muted">{t.codeFallback}</span>
)}
</div>
{code && (
<button type="button" class="btn btn-secondary invite-copy-btn" onClick={onCopy}>
{copied ? <Check /> : <Copy />}
<span>{copied ? t.copied : t.copy}</span>
</button>
)}
<div class="invite-rewards">
<div class="invite-rewards-title">
<Gift />
<span>{t.rewardsTitle}</span>
</div>
<ul class="invite-rewards-list">
<li>
<UserPlus />
<span>{t.reward1}</span>
</li>
<li>
<Gift />
<span>{t.reward2}</span>
</li>
</ul>
</div>
<div class="invite-steps">
<div class="invite-steps-title">{t.stepsTitle}</div>
<ol class="invite-steps-list">
<li>
<span class="n">1</span>
<span class="t">{t.step1}</span>
</li>
<li>
<span class="n">2</span>
<span class="t">{t.step2}</span>
</li>
<li>
<span class="n">3</span>
<span class="t">{t.step3}</span>
</li>
</ol>
</div>
<a class="btn btn-primary btn-lg invite-dl-btn" href={dlHref}>
<Download />
<span>{t.dl}</span>
<ArrowRight />
</a>
</div>
</div>
</section>
);
}
+71
View File
@@ -0,0 +1,71 @@
---
/**
* i.astro — 邀请落地页 `/i/`(产出 dist/i/index.html)。
*
* App 内分享的邀请链接固定形如 `https://pangolin.yanmeiai.com/i/<8位邀请码>`
* CF Pages 用 public/_redirects 的 `/i/* /i/ 200` 把任意邀请码路径都重写到这一份
* 静态产物;邀请码本身在浏览器端由 InviteCard.jsx 从 location.pathname 解析(构建期
* 不知道、也不可能为每个码单独出页)。
*
* 页面骨架复用全站 Header/Footer + tokens/website/site-extra 样式(与 layouts/Doc.astro
* 同一路数);Header/Footer 走全站 i18n 惯例整页单显中文(面向主力市场)。正文卡片
* InviteCard.jsx)另有独立的 zh/en 运行时切换,理由见该组件顶部注释。
*/
import '@fontsource/sora/500.css';
import '@fontsource/sora/600.css';
import '@fontsource/sora/700.css';
import '@fontsource/manrope/400.css';
import '@fontsource/manrope/500.css';
import '@fontsource/manrope/600.css';
import '@fontsource/manrope/700.css';
import '@fontsource/noto-sans-sc/400.css';
import '@fontsource/noto-sans-sc/500.css';
import '@fontsource/noto-sans-sc/700.css';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
import '../styles/tokens.gen.css';
import '../styles/website.css';
import '../styles/site-extra.css';
import { createT } from '../i18n/strings';
import Header from '../components/Header.jsx';
import Footer from '../components/Footer.astro';
import InviteCard from '../components/InviteCard.jsx';
const t = createT('zh');
const headerT = {
product: t('nav.product'),
pricing: t('nav.pricing'),
download: t('nav.download'),
docs: t('nav.docs'),
blog: t('nav.blog'),
login: t('nav.login'),
center: t('nav.center'),
brand: t('nav.brand'),
mcenter: t('menu.center'),
mswitch: t('menu.switch'),
mlogout: t('menu.logout'),
get: t('nav.get'),
suBtn: t('su.btn'),
};
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>邀请你使用穿山甲 · {t('nav.brand')}</title>
<meta name="description" content="好友邀请你加入穿山甲:注册双方各得 3 天,好友首次购买双方再得 7 天。" />
<meta name="robots" content="noindex,follow" />
<meta name="theme-color" content="#B96A3D" />
</head>
<body>
<Header client:load lang="zh" t={headerT} />
<main>
<InviteCard client:load />
</main>
<Footer t={t} />
</body>
</html>
+150
View File
@@ -190,3 +190,153 @@
white-space: nowrap;
border: 0;
}
/* ---------- invite landing (/i/<code>) ----------
邀请落地页正文卡片;只用现成 token(无新硬编码色),numbered-step 圆点复用
website.css 里 .step .n 的现成样式。见 InviteCard.jsx 顶部注释:本页语言切换
是运行时的(.langseg 胶囊开关),因为邀请码本身也只能在运行时从 URL 解析。 */
.invite-page {
padding: 56px 0 88px;
}
.invite-card {
max-width: 560px;
margin: 0 auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-lg);
padding: 36px 32px 32px;
display: flex;
flex-direction: column;
align-items: center;
}
.invite-langseg {
align-self: center;
margin-bottom: 18px;
}
.invite-hero {
margin-top: 8px;
max-width: 440px;
}
.invite-code-box {
width: 100%;
margin-top: 26px;
background: var(--bg-subtle);
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-xl);
padding: 18px 22px;
text-align: center;
}
.invite-code-label {
display: block;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg3);
}
.invite-code-value {
display: block;
margin-top: 8px;
font-family: var(--font-mono);
font-weight: 700;
font-size: 30px;
letter-spacing: 0.14em;
color: var(--accent);
}
.invite-code-value--muted {
font-size: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--fg2);
}
.invite-copy-btn {
margin-top: 16px;
}
.invite-rewards {
width: 100%;
margin-top: 30px;
background: var(--accent-subtle);
border: 1px solid var(--accent-border);
border-radius: var(--radius-lg);
padding: 16px 18px;
}
.invite-rewards-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
font-weight: 700;
color: var(--fg1);
}
.invite-rewards-title svg {
width: 16px;
height: 16px;
color: var(--accent);
}
.invite-rewards-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 9px;
}
.invite-rewards-list li {
display: flex;
align-items: flex-start;
gap: 9px;
font-size: 14px;
color: var(--fg2);
line-height: 1.5;
}
.invite-rewards-list svg {
width: 16px;
height: 16px;
color: var(--accent);
flex-shrink: 0;
margin-top: 2px;
}
.invite-steps {
width: 100%;
margin-top: 26px;
}
.invite-steps-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fg3);
}
.invite-steps-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.invite-steps-list li {
display: flex;
align-items: center;
gap: 12px;
}
.invite-steps-list .t {
font-size: 14.5px;
color: var(--fg1);
line-height: 1.5;
}
.invite-dl-btn {
width: 100%;
justify-content: center;
margin-top: 30px;
}
@media (max-width: 640px) {
.invite-card {
padding: 28px 20px 26px;
}
.invite-code-value {
font-size: 24px;
}
}