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:
@@ -0,0 +1,5 @@
|
||||
# Cloudflare Pages 重写规则。
|
||||
# 邀请落地页:App 分享链接固定形如 /i/<8位邀请码>;CF Pages 静态托管无路由参数,
|
||||
# 用重写把任意邀请码路径统一指到 /i/ 的静态产物,邀请码本身由页面 JS 从
|
||||
# location.pathname 解析(见 src/components/InviteCard.jsx)。
|
||||
/i/* /i/ 200
|
||||
@@ -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/I,2-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: 'Friend’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user