From ab84a3542a53cc7d199a6944b442d39274beed5e Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 12:50:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(website):=20=E9=82=80=E8=AF=B7/?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E9=A1=B5=E8=AF=AD=E8=A8=80=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E8=B7=AF=E7=94=B1(=E5=85=AD=E8=AF=AD?= =?UTF-8?q?=E5=8F=98=E4=BD=93),=E7=A7=BB=E9=99=A4=E5=8D=A1=E5=86=85=20zh/E?= =?UTF-8?q?N=20=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/website/public/_redirects | 12 +- .../src/components/AnnouncementBar.jsx | 12 +- web/website/src/components/Header.jsx | 9 +- web/website/src/components/Hero.astro | 7 +- web/website/src/components/InviteCard.jsx | 133 ++++++++++--- web/website/src/components/RegisterCard.jsx | 187 +++++++++++++++--- web/website/src/components/SignupForm.jsx | 12 +- web/website/src/i18n/strings.ts | 6 + web/website/src/layouts/InvitePage.astro | 75 +++++++ web/website/src/layouts/RegisterPage.astro | 73 +++++++ web/website/src/layouts/Site.astro | 4 +- web/website/src/lib/langPath.js | 17 ++ web/website/src/pages/es/i.astro | 5 + web/website/src/pages/es/register.astro | 5 + web/website/src/pages/i.astro | 72 +------ web/website/src/pages/ja/i.astro | 5 + web/website/src/pages/ja/register.astro | 5 + web/website/src/pages/ko/i.astro | 5 + web/website/src/pages/ko/register.astro | 5 + web/website/src/pages/register.astro | 71 +------ web/website/src/pages/ru/i.astro | 5 + web/website/src/pages/ru/register.astro | 5 + web/website/src/pages/zh/i.astro | 5 + web/website/src/pages/zh/register.astro | 5 + web/website/src/styles/site-extra.css | 11 +- 25 files changed, 528 insertions(+), 223 deletions(-) create mode 100644 web/website/src/layouts/InvitePage.astro create mode 100644 web/website/src/layouts/RegisterPage.astro create mode 100644 web/website/src/lib/langPath.js create mode 100644 web/website/src/pages/es/i.astro create mode 100644 web/website/src/pages/es/register.astro create mode 100644 web/website/src/pages/ja/i.astro create mode 100644 web/website/src/pages/ja/register.astro create mode 100644 web/website/src/pages/ko/i.astro create mode 100644 web/website/src/pages/ko/register.astro create mode 100644 web/website/src/pages/ru/i.astro create mode 100644 web/website/src/pages/ru/register.astro create mode 100644 web/website/src/pages/zh/i.astro create mode 100644 web/website/src/pages/zh/register.astro diff --git a/web/website/public/_redirects b/web/website/public/_redirects index 2b9fdfc..bb42c83 100644 --- a/web/website/public/_redirects +++ b/web/website/public/_redirects @@ -1,5 +1,11 @@ # Cloudflare Pages 重写规则。 -# 邀请落地页:App 分享链接固定形如 /i/<8位邀请码>;CF Pages 静态托管无路由参数, -# 用重写把任意邀请码路径统一指到 /i/ 的静态产物,邀请码本身由页面 JS 从 -# location.pathname 解析(见 src/components/InviteCard.jsx)。 +# 邀请落地页:App 分享链接固定形如 /i/<8位邀请码>(语言中立,默认落到英文根); +# 各语言前缀下也有对应的邀请落地页(/zh/i/、/ja/i/ 等,用户从 Header 切换语言后停留)。 +# CF Pages 静态托管无路由参数,用重写把任意邀请码路径统一指到各自语言的静态产物, +# 邀请码本身由页面 JS 从 location.pathname 解析(见 src/components/InviteCard.jsx)。 /i/* /i/ 200 +/zh/i/* /zh/i/ 200 +/ja/i/* /ja/i/ 200 +/ko/i/* /ko/i/ 200 +/ru/i/* /ru/i/ 200 +/es/i/* /es/i/ 200 diff --git a/web/website/src/components/AnnouncementBar.jsx b/web/website/src/components/AnnouncementBar.jsx index 8a19c3b..02faad7 100644 --- a/web/website/src/components/AnnouncementBar.jsx +++ b/web/website/src/components/AnnouncementBar.jsx @@ -1,15 +1,17 @@ /** * AnnouncementBar.jsx — 顶部公告条(React island)。迁自原型 .ann + site.js __closeAnn。 * - * CTA 直接跳独立注册页 /register(不再锚点滚动到 hero 的 #signup —— 那边现在本身也只是 - * 跳 /register 的一层转发,直达更省一跳)。已登录(见 lib/authState.js)时整条隐藏: - * 老用户不需要看到「新用户注册」的横幅。无 JS 时默认展示(渐进增强)。 + * CTA 直接跳**本语言**的独立注册页 `/register`(不再锚点滚动到 hero 的 #signup —— + * 那边现在本身也只是跳 register 的一层转发,直达更省一跳)。路径拼接走 + * lib/langPath.js,与 Header/SignupForm 同一实现。已登录(见 lib/authState.js)时整条 + * 隐藏:老用户不需要看到「新用户注册」的横幅。无 JS 时默认展示(渐进增强)。 */ import { useEffect, useState } from 'react'; import { Gift, X } from 'lucide-react'; import { isLoggedIn } from '../lib/authState'; +import { langPath } from '../lib/langPath'; -export default function AnnouncementBar({ text, cta }) { +export default function AnnouncementBar({ text, cta, lang = 'en' }) { const [closed, setClosed] = useState(false); const [loggedIn, setLoggedIn] = useState(false); @@ -23,7 +25,7 @@ export default function AnnouncementBar({ text, cta }) {
{text} - {cta} + {cta} diff --git a/web/website/src/components/Header.jsx b/web/website/src/components/Header.jsx index 4134fc5..33cda28 100644 --- a/web/website/src/components/Header.jsx +++ b/web/website/src/components/Header.jsx @@ -1,12 +1,14 @@ /** * Header.jsx — 交互式顶栏(React island,@astrojs/react 渲染 + 水合)。 * 迁自 design/ui_kits/website/index.html 的
+ site.js 的菜单/滚动毛玻璃逻辑。 - * 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。 + * 语言切换由原型的 JS 文本替换改为「路由跳转」(en=/, zh=/zh/),单显不并排(铁律 6); + * 拼路径统一走 lib/langPath.js,`pagePath` 非空时(邀请/注册页)原地切语言而非跳回首页。 */ import { useEffect, useRef, useState } from 'react'; import { Download, Menu } from 'lucide-react'; import { SITE } from '../config/site'; import { isLoggedIn, getStoredEmail, clearStoredSession } from '../lib/authState'; +import { langPath } from '../lib/langPath'; function Mark() { return ( @@ -27,7 +29,7 @@ function Mark() { ); } -export default function Header({ lang = 'zh', t = {} }) { +export default function Header({ lang = 'zh', t = {}, pagePath = '' }) { const [open, setOpen] = useState(false); const [scrolled, setScrolled] = useState(false); const [langOpen, setLangOpen] = useState(false); @@ -90,7 +92,8 @@ export default function Header({ lang = 'zh', t = {} }) { ['en', 'English'], ['zh', '中文'], ['ja', '日本語'], ['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'], ]; - const langHref = (l) => (l === 'en' ? '/' : `/${l}/`); + // pagePath 空串=首页;非空时(如 'i'/'register')保持在同一页面切语言,而不是跳回首页。 + const langHref = (l) => langPath(l, pagePath); return (
diff --git a/web/website/src/components/Hero.astro b/web/website/src/components/Hero.astro index 0dc6026..8d0e870 100644 --- a/web/website/src/components/Hero.astro +++ b/web/website/src/components/Hero.astro @@ -1,10 +1,10 @@ --- import Icon from './Icon.astro'; import SignupForm from './SignupForm.jsx'; -import type { T } from '../i18n/strings'; +import type { T, Lang } from '../i18n/strings'; -interface Props { t: T } -const { t } = Astro.props; +interface Props { t: T; lang: Lang } +const { t, lang } = Astro.props; ---
@@ -15,6 +15,7 @@ const { t } = Astro.props; 唯一动态内容)。 + * InviteCard.jsx — 邀请落地页正文(React island,/i/ 与 //i/ 唯一动态内容)。 * - * 这条路由本质是运行时的:CF Pages 用 `_redirects` 把 `/i/*` 统一重写到静态产物 - * `/i/index.html`,邀请码只存在于原始 URL 路径里,构建期完全不知道,所以只能在 - * 浏览器里从 `location.pathname` 取。也正因如此,本组件对「双语」的处理**不**走 - * 全站 `i18n/strings.ts` 的「构建期按路由单显」模式(那个模式要求语言在构建期已知), - * 而是在这一个组件内嵌 zh/en 两份文案、用 `.langseg`(website.css 里现成的双态胶囊 - * 开关,此前只有原型用过、构建期路由化后闲置)做「单显 + 切换」:任意时刻只渲染 - * 一种语言的文本,不并排堆叠,语义上与全站单显原则一致,只是切换时机在运行时。 - * Header/Footer 仍按全站惯例整页单显中文(面向主力市场:中国大陆用户)。 + * 这条路由本质是运行时的:CF Pages 用 `_redirects` 把每个语言前缀下的 `//i/*` + * (含英文根 `/i/*`)统一重写到对应静态产物 `//i/index.html`,邀请码本身只存在于 + * 原始 URL 路径里,构建期完全不知道,所以只能在浏览器里从 `location.pathname` 取。 * - * 主 CTA 是「注册领取 3 天」按钮,跳去独立注册页 /register(?invite=<码> 预填,见 - * RegisterCard.jsx),本页不再内联注册表单。已登录(同源 localStorage 里有 - * pg_uc_refresh,见 lib/authState.js)时把 CTA 换成提示文案——邀请码只在新账号 - * 注册时生效,老账号无法补绑。 + * 语言现在跟随站点路由:由父级 layout(InvitePage.astro)在构建期把 `lang` 当 prop 传入, + * 六语(zh/en/ja/ko/ru/es)文案单显,不再有组件内嵌的 zh/en 运行时切换开关。 + * Header/Footer 同样按该 `lang` 单显对应语言(全站惯例)。 + * + * 主 CTA 是「注册领取 3 天」按钮,跳去**本语言**的独立注册页 `/register` + * (?invite=<码> 预填,见 RegisterCard.jsx;路径拼接见 lib/langPath.js),本页不再内联 + * 注册表单。已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)时把 CTA + * 换成提示文案——邀请码只在新账号注册时生效,老账号无法补绑。 + * + * App 发出的邀请链接本身是语言中立的根路径 `/i/<码>`(默认落到英文),不做自动语言重定向 + * ——与主页行为一致,用户可用 Header 的语言下拉自行切换(切换会带上当前的邀请码路径,见 + * Header.jsx 的 `pagePath` 机制)。 */ import { useEffect, useMemo, useState } from 'react'; import { Copy, Check, Download, UserPlus, Gift, ArrowRight } from 'lucide-react'; import { isLoggedIn } from '../lib/authState'; import { INVITE_CODE_RE } from '../lib/inviteCode'; +import { langPath } from '../lib/langPath'; // 邀请码字母表定义单一实现在 lib/inviteCode.js(与 RegisterCard.jsx 的 ?invite= // 解析共用,避免两处定义漂移)。 @@ -36,6 +40,8 @@ function extractCode(pathname) { return INVITE_CODE_RE.test(raw) ? raw : null; } +// 文案沿用 App l10n 现成措辞(client/lib/l10n/strings_*.dart 的 invite*/auth 相关 getter), +// ja/ko/ru/es 依此自然本地化,非机翻。 const COPY = { zh: { eyebrow: '邀请', @@ -75,11 +81,86 @@ const COPY = { ctaRegister: 'Register & claim +3 days', ctaLoggedIn: "You're already signed in — invite codes only apply at sign-up", }, + ja: { + eyebrow: '招待', + heroWithCode: '友達があなたをPangolinに招待しました', + heroNoCode: 'Pangolinで、もっと快適なネットワークを', + codeLabel: '招待コード', + codeFallback: '共通招待', + copy: '招待コードをコピー', + copied: 'コピーしました', + rewardsTitle: '特典ルール', + reward1: '友達が登録 · 双方に3日間付与', + reward2: '友達が初回購入 · 双方に7日間付与', + stepsTitle: '受け取り方', + step1: 'Pangolinをダウンロード・インストール', + step2: '登録時に「招待コード(任意)」にこのコードを入力', + step3: '双方に自動で日数が加算されます', + dl: '無料ダウンロード', + ctaRegister: '登録して3日間を受け取る', + ctaLoggedIn: 'すでにログイン済みです — 招待コードは新規登録時のみ有効です', + }, + ko: { + eyebrow: '초대', + heroWithCode: '친구가 당신을 Pangolin에 초대했습니다', + heroNoCode: 'Pangolin과 함께, 더 매끄러운 네트워크를', + codeLabel: '초대 코드', + codeFallback: '공통 초대', + copy: '초대 코드 복사', + copied: '복사됨', + rewardsTitle: '보상 규칙', + reward1: '친구 가입 · 양쪽 각 3일 지급', + reward2: '친구 첫 구매 · 양쪽 각 7일 지급', + stepsTitle: '받는 방법', + step1: 'Pangolin 다운로드 및 설치', + step2: '가입 시 「초대 코드(선택)」에 이 코드를 입력', + step3: '양쪽 계정에 일수가 자동으로 적립됩니다', + dl: '무료 다운로드', + ctaRegister: '가입하고 3일 받기', + ctaLoggedIn: '이미 로그인되어 있습니다 — 초대 코드는 신규 가입 시에만 적용됩니다', + }, + ru: { + eyebrow: 'Приглашение', + heroWithCode: 'Друг пригласил вас попробовать Pangolin', + heroNoCode: 'Pangolin — быстрее и стабильнее в сети', + codeLabel: 'Код приглашения', + codeFallback: 'Общее приглашение', + copy: 'Скопировать код', + copied: 'Скопировано', + rewardsTitle: 'Правила наград', + reward1: 'Регистрация друга · +3 дня каждому', + reward2: 'Первая покупка друга · +7 дней каждому', + stepsTitle: 'Как получить', + step1: 'Скачайте и установите Pangolin', + step2: 'При регистрации введите этот код в поле «Код приглашения (необязательно)»', + step3: 'Дни автоматически зачисляются на оба аккаунта', + dl: 'Скачать бесплатно', + ctaRegister: 'Зарегистрироваться и получить +3 дня', + ctaLoggedIn: 'Вы уже вошли в аккаунт — коды приглашений действуют только при регистрации', + }, + es: { + eyebrow: 'Invitación', + heroWithCode: 'Un amigo te invitó a probar Pangolin', + heroNoCode: 'Prueba Pangolin — conexiones más fluidas y rápidas', + codeLabel: 'Código de invitación', + codeFallback: 'Invitación general', + copy: 'Copiar código', + copied: 'Copiado', + rewardsTitle: 'Reglas de recompensas', + reward1: 'Registro de amigo · +3 días cada uno', + reward2: 'Primera compra del amigo · +7 días cada uno', + stepsTitle: 'Cómo reclamar', + step1: 'Descarga e instala Pangolin', + step2: 'Introduce este código en «Código de invitación (opcional)» al registrarte', + step3: 'Los días se acreditan automáticamente en ambas cuentas', + dl: 'Descargar gratis', + ctaRegister: 'Regístrate y reclama +3 días', + ctaLoggedIn: 'Ya tienes sesión iniciada — los códigos de invitación solo aplican al registrarte', + }, }; -export default function InviteCard() { +export default function InviteCard({ lang = 'en' }) { const [code, setCode] = useState(null); - const [lang, setLang] = useState('zh'); const [copied, setCopied] = useState(false); const [loggedIn, setLoggedIn] = useState(false); @@ -88,13 +169,12 @@ export default function InviteCard() { setLoggedIn(isLoggedIn()); }, []); - useEffect(() => { - document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'; - }, [lang]); - - const t = COPY[lang]; - const dlHref = useMemo(() => `${lang === 'zh' ? '/zh/' : '/'}#download`, [lang]); - const registerHref = useMemo(() => (code ? `/register?invite=${code}` : '/register'), [code]); + const t = COPY[lang] || COPY.en; + const dlHref = useMemo(() => `${langPath(lang)}#download`, [lang]); + const registerHref = useMemo( + () => (code ? `${langPath(lang, 'register')}?invite=${code}` : langPath(lang, 'register')), + [lang, code], + ); async function onCopy() { if (!code) return; @@ -111,15 +191,6 @@ export default function InviteCard() {
-
- - -
-
{t.eyebrow}

{code ? t.heroWithCode : t.heroNoCode}

diff --git a/web/website/src/components/RegisterCard.jsx b/web/website/src/components/RegisterCard.jsx index ee2fd17..41d742a 100644 --- a/web/website/src/components/RegisterCard.jsx +++ b/web/website/src/components/RegisterCard.jsx @@ -1,8 +1,8 @@ /** - * RegisterCard.jsx — 独立注册页 `/register` 正文(React island)。 + * RegisterCard.jsx — 独立注册页 `/register` 与 `//register` 正文(React island)。 * * 承接两处入口:① 首页 hero「免费注册」表单跳转(?email= 预填);② 邀请落地页 - * `/i/`「注册领取 3 天」跳转(?invite= 预填)。二者共用同一套「验证码 + 设密码」 + * `//i/`「注册领取 3 天」跳转(?invite= 预填)。二者共用同一套「验证码 + 设密码」 * 注册流程,行为与后端契约见 server/internal/auth/handler.go: * * POST /v1/auth/code body {email} → 204 @@ -10,25 +10,30 @@ * (device 整个省略——web 无设备指纹,后端零值容忍) * → 200 {access_token,refresh_token,...}(网页不存,注册成功即弃) * - * 错误统一 {code,message_zh,message_en},按当前语言态取对应文案;网络错用通用双语兜底。 + * 错误统一 {code,message_zh,message_en};ja/ko/ru/es 后端暂无对应本地化字段,网络/接口错误 + * 一律回退本组件的六语通用兜底文案(不影响功能,仅错误提示语言与后端返回是否精确对应)。 * - * 双语处理与 InviteCard.jsx 同一路数:不走全站 i18n/strings.ts 的「构建期按路由单显」 - * (那个模式要求语言在构建期已知),而是本组件内嵌 zh/en 两份文案、用 `.langseg` - * 单显 + 运行时切换 —— 任意时刻只渲染一种语言。 + * 语言现在跟随站点路由:由父级 layout(RegisterPage.astro)在构建期把 `lang` 当 prop + * 传入,六语单显,不再有组件内嵌的 zh/en 运行时切换开关。 * * 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)直接短路成 - * 「你已登录」提示,不渲染注册表单(无 JS 时默认展示表单,渐进增强)。 + * 「你已登录」提示,不渲染注册表单(无 JS 时默认展示表单,渐进增强)。注册成功后倒计时 + * 自动跳回**本语言**首页(`langPath(lang)`)。 */ import { useEffect, useMemo, useRef, useState } from 'react'; import { Mail, KeyRound, Lock, Loader2, AlertCircle, CheckCircle2, Download, ArrowRight } from 'lucide-react'; import { SITE } from '../config/site'; import { isLoggedIn } from '../lib/authState'; import { normalizeInviteCode } from '../lib/inviteCode'; +import { langPath } from '../lib/langPath'; const API_BASE = import.meta.env.PUBLIC_API_BASE || SITE.api; const RESEND_SECS = 60; const REDIRECT_SECS = 3; +// 文案沿用 App l10n 现成措辞(client/lib/l10n/strings_*.dart 的 auth 相关 getter: +// emailLabel/pwLabel/codeLabel/sendCode/resend/tabRegister 等),ja/ko/ru/es 依此自然 +// 本地化;网络错误/表单校验/成功页等站点特有文案参照全站 i18n/strings.ts 的口吻续写,非机翻。 const COPY = { zh: { eyebrow: '注册', @@ -102,6 +107,150 @@ const COPY = { loggedInHome: 'Back to home', loggedInCenter: 'Open account center', }, + ja: { + eyebrow: '登録', + titleInvite: '登録して、あなたと友達に3日間ずつ付与', + titlePlain: 'Pangolinアカウントを作成', + sub: '必要なのはメールアドレスだけ。登録は無料、支払い情報は不要です。', + emailLabel: 'メールアドレス', + emailPh: 'メールアドレスを入力', + sendCode: 'コードを送信', + resend: '再送信', + resendIn: (n) => `${n}秒後に再送信可能`, + sending: '送信中…', + codeLabel: '確認コード', + codePh: '6桁のコード', + pwLabel: 'パスワードを設定', + pwPh: '8文字以上', + pwHint: 'パスワードは8文字以上で入力してください', + submitInvite: '登録して+3日間を受け取る', + submitPlain: '登録', + submitting: '登録中…', + netErr: 'ネットワークエラーです。接続を確認して再試行してください', + emailInvalid: '正しいメールアドレスを入力してください', + formIncomplete: '確認コードとパスワード(8文字以上)を入力してください', + successTitle: '登録完了', + successSubInvite: '+3日間が付与されました。このメールアドレスでアプリにログインしてご確認ください。', + successSubPlain: 'Pangolinをダウンロードし、このメールアドレスでログインしてください。', + dlMac: 'macOS版をダウンロード', + dlWin: 'Windows版をダウンロード', + dlAndroid: 'Android版をダウンロード', + dlGeneric: 'ダウンロードページへ', + backHome: 'ホームに戻る', + redirecting: (n) => `${n}秒後にホームへ自動的に戻ります`, + loggedInTitle: 'すでにログイン済みです', + loggedInSub: '再登録の必要はありません。ホームまたはアカウントセンターへどうぞ。', + loggedInHome: 'ホームに戻る', + loggedInCenter: 'アカウントセンターを開く', + }, + ko: { + eyebrow: '가입', + titleInvite: '가입하고 나와 친구 모두 +3일 받기', + titlePlain: 'Pangolin 계정 만들기', + sub: '이메일 하나로 시작. 가입은 무료이며 결제 정보가 필요 없습니다.', + emailLabel: '이메일', + emailPh: '이메일 주소', + sendCode: '코드 전송', + resend: '재전송', + resendIn: (n) => `${n}초 후 재전송 가능`, + sending: '전송 중…', + codeLabel: '인증 코드', + codePh: '6자리 코드', + pwLabel: '비밀번호 설정', + pwPh: '8자 이상', + pwHint: '비밀번호는 8자 이상이어야 합니다', + submitInvite: '가입하고 +3일 받기', + submitPlain: '가입', + submitting: '가입 중…', + netErr: '네트워크 오류입니다. 연결을 확인한 후 다시 시도해 주세요', + emailInvalid: '올바른 이메일 주소를 입력해 주세요', + formIncomplete: '인증 코드와 비밀번호(8자 이상)를 모두 입력해 주세요', + successTitle: '가입 완료', + successSubInvite: '+3일이 적립되었습니다. 이 이메일로 앱에 로그인해 확인하세요.', + successSubPlain: 'Pangolin을 다운로드하고 이 이메일로 로그인하세요.', + dlMac: 'macOS용 다운로드', + dlWin: 'Windows용 다운로드', + dlAndroid: 'Android용 다운로드', + dlGeneric: '다운로드 페이지로', + backHome: '홈으로 돌아가기', + redirecting: (n) => `${n}초 후 홈으로 자동 이동합니다`, + loggedInTitle: '이미 로그인되어 있습니다', + loggedInSub: '다시 가입할 필요 없이 홈으로 가거나 계정 센터를 열어보세요.', + loggedInHome: '홈으로 돌아가기', + loggedInCenter: '계정 센터 열기', + }, + ru: { + eyebrow: 'Регистрация', + titleInvite: 'Зарегистрируйтесь — вы и друг получите по +3 дня', + titlePlain: 'Создайте аккаунт Pangolin', + sub: 'Для начала нужна только почта. Регистрация бесплатна, платёжные данные не требуются.', + emailLabel: 'Эл. почта', + emailPh: 'Ваша почта', + sendCode: 'Отправить код', + resend: 'Отправить снова', + resendIn: (n) => `Повторно через ${n} с`, + sending: 'Отправка…', + codeLabel: 'Код подтверждения', + codePh: '6-значный код', + pwLabel: 'Задайте пароль', + pwPh: 'Минимум 8 символов', + pwHint: 'Пароль должен содержать не менее 8 символов', + submitInvite: 'Зарегистрироваться и получить +3 дня', + submitPlain: 'Зарегистрироваться', + submitting: 'Регистрация…', + netErr: 'Ошибка сети, проверьте соединение и попробуйте снова', + emailInvalid: 'Введите корректный адрес электронной почты', + formIncomplete: 'Заполните код подтверждения и пароль (минимум 8 символов)', + successTitle: 'Регистрация завершена', + successSubInvite: 'Ваши +3 дня зачислены. Войдите в приложение с этой почтой, чтобы увидеть их.', + successSubPlain: 'Скачайте Pangolin и войдите с этой почтой.', + dlMac: 'Скачать для macOS', + dlWin: 'Скачать для Windows', + dlAndroid: 'Скачать для Android', + dlGeneric: 'Перейти к загрузкам', + backHome: 'На главную', + redirecting: (n) => `Возврат на главную через ${n} с`, + loggedInTitle: 'Вы уже вошли в аккаунт', + loggedInSub: 'Повторная регистрация не нужна — вернитесь на главную или откройте личный кабинет.', + loggedInHome: 'На главную', + loggedInCenter: 'Открыть личный кабинет', + }, + es: { + eyebrow: 'Registro', + titleInvite: 'Regístrate: tú y tu amigo ganan +3 días cada uno', + titlePlain: 'Crea tu cuenta de Pangolin', + sub: 'Solo necesitas un correo para empezar. Registro gratis, sin datos de pago.', + emailLabel: 'Correo', + emailPh: 'Tu correo', + sendCode: 'Enviar código', + resend: 'Reenviar', + resendIn: (n) => `Reenviar en ${n}s`, + sending: 'Enviando…', + codeLabel: 'Código de verificación', + codePh: 'Código de 6 dígitos', + pwLabel: 'Crea una contraseña', + pwPh: 'Al menos 8 caracteres', + pwHint: 'La contraseña debe tener al menos 8 caracteres', + submitInvite: 'Regístrate y reclama +3 días', + submitPlain: 'Registrarse', + submitting: 'Registrando…', + netErr: 'Error de red, comprueba tu conexión e inténtalo de nuevo', + emailInvalid: 'Introduce una dirección de correo válida', + formIncomplete: 'Completa el código y una contraseña (al menos 8 caracteres)', + successTitle: 'Registro completado', + successSubInvite: 'Tus +3 días ya están acreditados. Inicia sesión en la app con este correo para verlos.', + successSubPlain: 'Descarga Pangolin e inicia sesión con este correo.', + dlMac: 'Descargar para macOS', + dlWin: 'Descargar para Windows', + dlAndroid: 'Descargar para Android', + dlGeneric: 'Ir a descargas', + backHome: 'Volver al inicio', + redirecting: (n) => `Volviendo al inicio en ${n}s`, + loggedInTitle: 'Ya tienes sesión iniciada', + loggedInSub: 'No necesitas registrarte de nuevo: vuelve al inicio o abre tu cuenta.', + loggedInHome: 'Volver al inicio', + loggedInCenter: 'Abrir mi cuenta', + }, }; function detectPlatform() { @@ -139,8 +288,7 @@ async function callApi(path, body) { return { ok: true, data }; } -export default function RegisterCard() { - const [lang, setLang] = useState('zh'); +export default function RegisterCard({ lang = 'en' }) { const [loggedIn, setLoggedIn] = useState(false); const [checkedAuth, setCheckedAuth] = useState(false); @@ -164,9 +312,10 @@ export default function RegisterCard() { const redirectRef = useRef(null); const codeInputRef = useRef(null); - const t = COPY[lang]; + const t = COPY[lang] || COPY.en; + // 后端错误字段只有 message_zh/message_en 两语;非中文一律取英文兜底(见组件顶部注释)。 const msgKey = lang === 'zh' ? 'message_zh' : 'message_en'; - const homeHref = lang === 'zh' ? '/zh/' : '/'; + const homeHref = langPath(lang); const dlAnchorHref = `${homeHref}#download`; // 读 query 参数(?email=&invite=) + 登录态;均只在挂载时读一次。 @@ -179,15 +328,10 @@ export default function RegisterCard() { } catch { /* ignore malformed query */ } - document.documentElement.lang = 'zh-CN'; setLoggedIn(isLoggedIn()); setCheckedAuth(true); }, []); - useEffect(() => { - document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'; - }, [lang]); - useEffect(() => () => clearInterval(cooldownRef.current), []); useEffect(() => () => clearInterval(redirectRef.current), []); @@ -278,13 +422,6 @@ export default function RegisterCard() { const pwInvalid = pwTouched && password.length > 0 && password.length < 8; const canSubmit = /\S+@\S+\.\S+/.test(email.trim()) && code.length === 6 && password.length >= 8 && !submitting; - const LangSeg = ( -
- - -
- ); - // 登录态检测前先不下结论(避免闪一下已登录态又变表单);未挂载完成时按未登录渲染, // 与「无 JS 时默认展示注册表单」的渐进增强保持一致。 if (checkedAuth && loggedIn) { @@ -292,7 +429,6 @@ export default function RegisterCard() {
- {LangSeg}

{t.loggedInTitle}

@@ -313,7 +449,6 @@ export default function RegisterCard() {
- {LangSeg}

{t.successTitle}

@@ -336,8 +471,6 @@ export default function RegisterCard() {
- {LangSeg} -
{t.eyebrow}

{invite ? t.titleInvite : t.titlePlain}

{t.sub}

diff --git a/web/website/src/components/SignupForm.jsx b/web/website/src/components/SignupForm.jsx index a7107fb..124511e 100644 --- a/web/website/src/components/SignupForm.jsx +++ b/web/website/src/components/SignupForm.jsx @@ -1,9 +1,10 @@ /** * SignupForm.jsx — Hero 内联邮箱入口(React island)。迁自原型 .signup + site.js __signup。 * - * 不在这里发验证码 / 建账户:提交只是把邮箱透传给独立注册页 /register(?email= 预填), - * 真正的「验证码 + 设密码」注册流程由 RegisterCard.jsx 承接(见该组件顶部注释)。 - * 空邮箱也允许跳转——/register 页留空即可,用户到那边再填。 + * 不在这里发验证码 / 建账户:提交只是把邮箱透传给**本语言**的独立注册页 + * `/register`(?email= 预填),真正的「验证码 + 设密码」注册流程由 RegisterCard.jsx + * 承接(见该组件顶部注释)。空邮箱也允许跳转——register 页留空即可,用户到那边再填。 + * 路径拼接走 lib/langPath.js,与 Header 语言切换、InviteCard/RegisterCard 同一实现。 * * 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)时,表单换成 * 「打开用户中心」按钮:老用户不需要再看到注册入口。无 JS 时默认展示表单(渐进增强)。 @@ -12,8 +13,9 @@ import { useEffect, useState } from 'react'; import { Mail, Download } from 'lucide-react'; import { SITE } from '../config/site'; import { isLoggedIn } from '../lib/authState'; +import { langPath } from '../lib/langPath'; -export default function SignupForm({ ph, btn, hint, dl, centerLabel }) { +export default function SignupForm({ lang = 'en', ph, btn, hint, dl, centerLabel }) { const [loggedIn, setLoggedIn] = useState(false); useEffect(() => { @@ -25,7 +27,7 @@ export default function SignupForm({ ph, btn, hint, dl, centerLabel }) { const emailEl = e.currentTarget.elements['su-email']; const email = (emailEl && emailEl.value ? emailEl.value : '').trim(); const qs = email ? `?email=${encodeURIComponent(email)}` : ''; - window.location.href = `/register${qs}`; + window.location.href = `${langPath(lang, 'register')}${qs}`; }; if (loggedIn) { diff --git a/web/website/src/i18n/strings.ts b/web/website/src/i18n/strings.ts index 56fccd4..e3ce113 100644 --- a/web/website/src/i18n/strings.ts +++ b/web/website/src/i18n/strings.ts @@ -185,6 +185,12 @@ export const STRINGS: Record> = { 'ft.copy': { zh: '© 2026 穿山甲 · Pangolin', en: '© 2026 Pangolin', ja: '© 2026 Pangolin', ko: '© 2026 Pangolin', ru: '© 2026 Pangolin', es: '© 2026 Pangolin' }, 'ft.madenote': { zh: 'App 内不支持直接支付 · 资金流全走外部渠道', en: 'No in-app payment · all purchases via external channels', ja: 'アプリ内決済なし · すべての購入は外部窓口で', ko: '앱 내 결제 없음 · 모든 구매는 외부 채널로', ru: 'Без оплаты в приложении · все покупки через внешние каналы', es: 'Sin pago en la app · todas las compras por canales externos' }, + // 邀请落地页 /i //i /meta(六语) + 'inv.metaTitle': { zh: '邀请你使用穿山甲 · Pangolin', en: "You're invited to try Pangolin", ja: 'Pangolinに招待されました', ko: 'Pangolin에 초대되었습니다', ru: 'Вас пригласили в Pangolin', es: 'Te han invitado a probar Pangolin' }, + 'inv.metaDesc': { zh: '好友邀请你加入穿山甲:注册双方各得 3 天,好友首次购买双方再得 7 天。', en: 'A friend invited you to Pangolin: sign up and you both get +3 days, +7 more on their first purchase.', ja: '友達があなたをPangolinに招待しました:登録で双方に3日間、友達の初回購入でさらに双方に7日間付与されます。', ko: '친구가 당신을 Pangolin에 초대했습니다: 가입하면 양쪽 모두 3일, 친구의 첫 구매 시 양쪽 모두 7일이 추가로 지급됩니다.', ru: 'Друг пригласил вас в Pangolin: зарегистрируйтесь — и вы оба получите +3 дня, а при первой покупке друга — ещё +7 дней каждому.', es: 'Un amigo te invitó a Pangolin: regístrate y ambos ganan +3 días, y +7 días más en su primera compra.' }, + // 注册页 /register /<lang>/register <title>/meta(六语) + 'reg.metaTitle': { zh: '注册 · Pangolin', en: 'Sign up · Pangolin', ja: '新規登録 · Pangolin', ko: '회원가입 · Pangolin', ru: 'Регистрация · Pangolin', es: 'Registrarse · Pangolin' }, + 'reg.metaDesc': { zh: '创建你的穿山甲账户:一个邮箱即可开始,免费注册,无需任何付款信息。', en: 'Create your Pangolin account: just an email to start, free to join, no payment details.', ja: 'Pangolinアカウントを作成:メールアドレスだけで開始でき、登録は無料、支払い情報は不要です。', ko: 'Pangolin 계정 만들기: 이메일만 있으면 시작할 수 있고, 가입은 무료이며 결제 정보도 필요 없습니다.', ru: 'Создайте аккаунт Pangolin: для начала нужна только почта, регистрация бесплатна, платёжные данные не требуются.', es: 'Crea tu cuenta de Pangolin: solo necesitas un correo para empezar, registro gratis, sin datos de pago.' }, // 页面 <title> / meta(六语) 'meta.title': { zh: '穿山甲 · Pangolin — 极速 · 稳定 · 省心', en: 'Pangolin — Fast · Stable · Effortless', ja: 'Pangolin — 高速 · 安定 · 快適', ko: 'Pangolin — 빠름 · 안정 · 간편', ru: 'Pangolin — Быстро · Стабильно · Без забот', es: 'Pangolin — Rápido · Estable · Sin complicaciones' }, 'meta.desc': { zh: '轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', en: 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.', ja: '軽快で使いやすい、クロスプラットフォームのネットワークアクセラレーター。ワンタップ接続、スマートルーティング、安定した通信。', ko: '가볍고 친근한 크로스플랫폼 네트워크 가속 앱. 원 탭 연결, 스마트 라우팅, 흔들림 없는 안정성.', ru: 'Лёгкий и удобный кроссплатформенный ускоритель сети. Одно касание, умная маршрутизация, надёжное соединение.', es: 'Un acelerador de red multiplataforma, ligero y fácil de usar. Un toque, enrutamiento inteligente, total estabilidad.' }, diff --git a/web/website/src/layouts/InvitePage.astro b/web/website/src/layouts/InvitePage.astro new file mode 100644 index 0000000..69798f8 --- /dev/null +++ b/web/website/src/layouts/InvitePage.astro @@ -0,0 +1,75 @@ +--- +/** + * InvitePage.astro — 邀请落地页布局(/i/ 与 /<lang>/i/ 共用,构建期单显一种语言)。 + * + * App 内分享的邀请链接固定形如 `https://pangolin.yanmeiai.com/i/<8位邀请码>`(语言中立, + * 默认落到英文根 /i/);CF Pages 用 public/_redirects 给每个语言前缀各配一条 + * `/<lang>/i/* /<lang>/i/ 200` 把任意邀请码路径都重写到对应静态产物,邀请码本身在浏览器端 + * 由 InviteCard.jsx 从 location.pathname 解析(构建期不知道、也不可能为每个码单独出页)。 + * + * 页面骨架复用全站 Header/Footer + tokens/website/site-extra 样式(与 Site.astro/Doc.astro + * 同一路数);Header/Footer 与正文卡片(InviteCard.jsx)均走全站 i18n 路由式单显——不再有 + * 组件内嵌的运行时 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, type Lang } from '../i18n/strings'; +import Header from '../components/Header.jsx'; +import Footer from '../components/Footer.astro'; +import InviteCard from '../components/InviteCard.jsx'; + +interface Props { lang: Lang } +const { lang } = Astro.props; +const t = createT(lang); + +const HTML_LANG: Record<Lang, string> = { zh: 'zh-CN', en: 'en', ja: 'ja', ko: 'ko', ru: 'ru', es: 'es' }; + +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={HTML_LANG[lang]}> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{t('inv.metaTitle')} + + + + + +
+
+ +
+