From 254db4f40dc854a0806e97d287d167c7ed38de25 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 12:04:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(website):=20=E9=82=80=E8=AF=B7=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E9=A1=B5=20/i/=20=E2=80=94=E2=80=94=20=E6=98=BE?= =?UTF-8?q?=E7=A0=81+=E5=A4=8D=E5=88=B6+=E5=A5=96=E5=8A=B1=E8=AF=B4?= =?UTF-8?q?=E6=98=8E+=E4=B8=8B=E8=BD=BD=E5=BC=95=E5=AF=BC(CF=20Pages=20=5F?= =?UTF-8?q?redirects=20=E9=87=8D=E5=86=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu --- web/website/public/_redirects | 5 + web/website/src/components/InviteCard.jsx | 173 ++++++++++++++++++++++ web/website/src/pages/i.astro | 71 +++++++++ web/website/src/styles/site-extra.css | 150 +++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 web/website/public/_redirects create mode 100644 web/website/src/components/InviteCard.jsx create mode 100644 web/website/src/pages/i.astro diff --git a/web/website/public/_redirects b/web/website/public/_redirects new file mode 100644 index 0000000..2b9fdfc --- /dev/null +++ b/web/website/public/_redirects @@ -0,0 +1,5 @@ +# Cloudflare Pages 重写规则。 +# 邀请落地页:App 分享链接固定形如 /i/<8位邀请码>;CF Pages 静态托管无路由参数, +# 用重写把任意邀请码路径统一指到 /i/ 的静态产物,邀请码本身由页面 JS 从 +# location.pathname 解析(见 src/components/InviteCard.jsx)。 +/i/* /i/ 200 diff --git a/web/website/src/components/InviteCard.jsx b/web/website/src/components/InviteCard.jsx new file mode 100644 index 0000000..d1d6755 --- /dev/null +++ b/web/website/src/components/InviteCard.jsx @@ -0,0 +1,173 @@ +/** + * InviteCard.jsx — 邀请落地页正文(React island,/i/ 唯一动态内容)。 + * + * 这条路由本质是运行时的: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 ( +
+
+
+
+ + +
+ +
{t.eyebrow}
+

{code ? t.heroWithCode : t.heroNoCode}

+ +
+ {t.codeLabel} + {code ? ( + {code} + ) : ( + {t.codeFallback} + )} +
+ + {code && ( + + )} + +
+
+ + {t.rewardsTitle} +
+
    +
  • + + {t.reward1} +
  • +
  • + + {t.reward2} +
  • +
+
+ +
+
{t.stepsTitle}
+
    +
  1. + 1 + {t.step1} +
  2. +
  3. + 2 + {t.step2} +
  4. +
  5. + 3 + {t.step3} +
  6. +
+
+ + + + {t.dl} + + +
+
+
+ ); +} diff --git a/web/website/src/pages/i.astro b/web/website/src/pages/i.astro new file mode 100644 index 0000000..86d2691 --- /dev/null +++ b/web/website/src/pages/i.astro @@ -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'), +}; +--- + + + + + + 邀请你使用穿山甲 · {t('nav.brand')} + + + + + +
+
+ +
+