refactor(website): 邀请/注册页语言跟随站点路由(六语变体),移除卡内 zh/EN 切换
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 22s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 8s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 22s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 9s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 22s
ci-pangolin / Go — build + test (pull_request) Failing after 17s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 9s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m30s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 12m46s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 12m57s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 13m6s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 22s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 8s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 22s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 9s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 22s
ci-pangolin / Go — build + test (pull_request) Failing after 17s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 9s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m30s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 12m46s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 12m57s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 13m6s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* AnnouncementBar.jsx — 顶部公告条(React island)。迁自原型 .ann + site.js __closeAnn。
|
||||
*
|
||||
* CTA 直接跳独立注册页 /register(不再锚点滚动到 hero 的 #signup —— 那边现在本身也只是
|
||||
* 跳 /register 的一层转发,直达更省一跳)。已登录(见 lib/authState.js)时整条隐藏:
|
||||
* 老用户不需要看到「新用户注册」的横幅。无 JS 时默认展示(渐进增强)。
|
||||
* CTA 直接跳**本语言**的独立注册页 `<lang>/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 }) {
|
||||
<div class="wrap ann-in">
|
||||
<Gift />
|
||||
<span>{text}</span>
|
||||
<a href="/register">{cta}</a>
|
||||
<a href={langPath(lang, 'register')}>{cta}</a>
|
||||
<button class="ann-x" onClick={() => setClosed(true)} aria-label="close">
|
||||
<X />
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Header.jsx — 交互式顶栏(React island,@astrojs/react 渲染 + 水合)。
|
||||
* 迁自 design/ui_kits/website/index.html 的 <header> + 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 (
|
||||
<header class={`hdr${scrolled ? ' scrolled' : ''}`}>
|
||||
|
||||
@@ -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;
|
||||
---
|
||||
<section class="hero">
|
||||
<div class="wrap grid">
|
||||
@@ -15,6 +15,7 @@ const { t } = Astro.props;
|
||||
|
||||
<SignupForm
|
||||
client:idle
|
||||
lang={lang}
|
||||
ph={t('su.ph')}
|
||||
btn={t('su.btn')}
|
||||
hint={t('su.hint')}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
/**
|
||||
* InviteCard.jsx — 邀请落地页正文(React island,/i/<code> 唯一动态内容)。
|
||||
* InviteCard.jsx — 邀请落地页正文(React island,/i/ 与 /<lang>/i/ 唯一动态内容)。
|
||||
*
|
||||
* 这条路由本质是运行时的:CF Pages 用 `_redirects` 把 `/i/*` 统一重写到静态产物
|
||||
* `/i/index.html`,邀请码只存在于原始 URL 路径里,构建期完全不知道,所以只能在
|
||||
* 浏览器里从 `location.pathname` 取。也正因如此,本组件对「双语」的处理**不**走
|
||||
* 全站 `i18n/strings.ts` 的「构建期按路由单显」模式(那个模式要求语言在构建期已知),
|
||||
* 而是在这一个组件内嵌 zh/en 两份文案、用 `.langseg`(website.css 里现成的双态胶囊
|
||||
* 开关,此前只有原型用过、构建期路由化后闲置)做「单显 + 切换」:任意时刻只渲染
|
||||
* 一种语言的文本,不并排堆叠,语义上与全站单显原则一致,只是切换时机在运行时。
|
||||
* Header/Footer 仍按全站惯例整页单显中文(面向主力市场:中国大陆用户)。
|
||||
* 这条路由本质是运行时的:CF Pages 用 `_redirects` 把每个语言前缀下的 `/<lang>/i/*`
|
||||
* (含英文根 `/i/*`)统一重写到对应静态产物 `/<lang>/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 天」按钮,跳去**本语言**的独立注册页 `<lang>/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() {
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* RegisterCard.jsx — 独立注册页 `/register` 正文(React island)。
|
||||
* RegisterCard.jsx — 独立注册页 `/register` 与 `/<lang>/register` 正文(React island)。
|
||||
*
|
||||
* 承接两处入口:① 首页 hero「免费注册」表单跳转(?email= 预填);② 邀请落地页
|
||||
* `/i/<code>`「注册领取 3 天」跳转(?invite= 预填)。二者共用同一套「验证码 + 设密码」
|
||||
* `/<lang>/i/<code>`「注册领取 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 = (
|
||||
<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>
|
||||
);
|
||||
|
||||
// 登录态检测前先不下结论(避免闪一下已登录态又变表单);未挂载完成时按未登录渲染,
|
||||
// 与「无 JS 时默认展示注册表单」的渐进增强保持一致。
|
||||
if (checkedAuth && loggedIn) {
|
||||
@@ -292,7 +429,6 @@ export default function RegisterCard() {
|
||||
<section class="invite-page register-page">
|
||||
<div class="wrap">
|
||||
<div class="invite-card register-card">
|
||||
{LangSeg}
|
||||
<div class="register-loggedin">
|
||||
<CheckCircle2 class="register-success-icon" />
|
||||
<h1 class="h-sec center register-hero">{t.loggedInTitle}</h1>
|
||||
@@ -313,7 +449,6 @@ export default function RegisterCard() {
|
||||
<section class="invite-page register-page">
|
||||
<div class="wrap">
|
||||
<div class="invite-card register-card">
|
||||
{LangSeg}
|
||||
<div class="register-success">
|
||||
<CheckCircle2 class="register-success-icon" />
|
||||
<h1 class="h-sec center register-hero">{t.successTitle}</h1>
|
||||
@@ -336,8 +471,6 @@ export default function RegisterCard() {
|
||||
<section class="invite-page register-page">
|
||||
<div class="wrap">
|
||||
<div class="invite-card register-card">
|
||||
{LangSeg}
|
||||
|
||||
<div class="eyebrow center">{t.eyebrow}</div>
|
||||
<h1 class="h-sec center register-hero">{invite ? t.titleInvite : t.titlePlain}</h1>
|
||||
<p class="sub-sec center">{t.sub}</p>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* SignupForm.jsx — Hero 内联邮箱入口(React island)。迁自原型 .signup + site.js __signup。
|
||||
*
|
||||
* 不在这里发验证码 / 建账户:提交只是把邮箱透传给独立注册页 /register(?email= 预填),
|
||||
* 真正的「验证码 + 设密码」注册流程由 RegisterCard.jsx 承接(见该组件顶部注释)。
|
||||
* 空邮箱也允许跳转——/register 页留空即可,用户到那边再填。
|
||||
* 不在这里发验证码 / 建账户:提交只是把邮箱透传给**本语言**的独立注册页
|
||||
* `<lang>/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) {
|
||||
|
||||
@@ -185,6 +185,12 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
|
||||
'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 /<lang>/i <title>/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.' },
|
||||
|
||||
@@ -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')}</title>
|
||||
<meta name="description" content={t('inv.metaDesc')} />
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
<meta name="theme-color" content="#B96A3D" />
|
||||
</head>
|
||||
<body>
|
||||
<Header client:load lang={lang} t={headerT} pagePath="i" />
|
||||
<main>
|
||||
<InviteCard client:load lang={lang} />
|
||||
</main>
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
/**
|
||||
* RegisterPage.astro — 独立注册页布局(/register/ 与 /<lang>/register/ 共用,构建期单显一种语言)。
|
||||
*
|
||||
* 两处入口跳转到这里:① 首页 hero 免费注册表单(?email= 预填,见 SignupForm.jsx);
|
||||
* ② 邀请落地页 /i/<code>(及各语言变体)的「注册领取 3 天」CTA(?invite= 预填,见
|
||||
* InviteCard.jsx)。二者共用同一套「验证码 + 设密码」注册流程(RegisterCard.jsx)。
|
||||
*
|
||||
* 页面骨架复用全站 Header/Footer + tokens/website/site-extra 样式,与 InvitePage.astro 同一
|
||||
* 路数;Header/Footer 与正文卡片(RegisterCard.jsx)均走全站 i18n 路由式单显。
|
||||
*/
|
||||
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 RegisterCard from '../components/RegisterCard.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('reg.metaTitle')}</title>
|
||||
<meta name="description" content={t('reg.metaDesc')} />
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
<meta name="theme-color" content="#B96A3D" />
|
||||
</head>
|
||||
<body>
|
||||
<Header client:load lang={lang} t={headerT} pagePath="register" />
|
||||
<main>
|
||||
<RegisterCard client:load lang={lang} />
|
||||
</main>
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -85,12 +85,12 @@ const headerT = {
|
||||
<meta property="og:locale" content={OG_LOCALE[lang]} />
|
||||
</head>
|
||||
<body>
|
||||
<AnnouncementBar client:idle text={t('ann.txt')} cta={t('ann.cta')} />
|
||||
<AnnouncementBar client:idle text={t('ann.txt')} cta={t('ann.cta')} lang={lang} />
|
||||
<Header client:load lang={lang} t={headerT} />
|
||||
|
||||
<a id="top"></a>
|
||||
|
||||
<Hero t={t} />
|
||||
<Hero t={t} lang={lang} />
|
||||
<Features t={t} />
|
||||
<WhySignup t={t} />
|
||||
<Pricing t={t} lang={lang} />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* langPath.js — 语言路由路径拼接单一实现(六语,默认 en=根路径,站点 trailingSlash:'always')。
|
||||
*
|
||||
* Header 语言切换下拉、Hero 注册跳转、邀请/注册卡 CTA 等多处都要「给定语言 + 页面片段,
|
||||
* 拼出对应路径」,统一走这里,避免各组件各写一份 `lang === 'zh' ? '/zh/' : '/'` 判断漂移。
|
||||
*/
|
||||
export const ALL_LANGS = ['en', 'zh', 'ja', 'ko', 'ru', 'es'];
|
||||
|
||||
/**
|
||||
* 语言 + 页面片段(不含首尾斜杠;省略或空串 = 首页)→ 绝对路径,末尾带斜杠。
|
||||
* 例:langPath('zh') → '/zh/';langPath('en', 'register') → '/register/';
|
||||
* langPath('ja', 'i') → '/ja/i/'。
|
||||
*/
|
||||
export function langPath(lang, page = '') {
|
||||
const seg = page ? `${page}/` : '';
|
||||
return lang === 'en' ? `/${seg}` : `/${lang}/${seg}`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// es 路由(/es/i/)。
|
||||
import InvitePage from '../../layouts/InvitePage.astro';
|
||||
---
|
||||
<InvitePage lang="es" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// es 路由(/es/register/)。
|
||||
import RegisterPage from '../../layouts/RegisterPage.astro';
|
||||
---
|
||||
<RegisterPage lang="es" />
|
||||
@@ -1,71 +1,5 @@
|
||||
---
|
||||
/**
|
||||
* 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'),
|
||||
};
|
||||
// 英文根路由(/i/)。App 分享的邀请链接默认语言中立地落在这里。
|
||||
import InvitePage from '../layouts/InvitePage.astro';
|
||||
---
|
||||
<!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>
|
||||
<InvitePage lang="en" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ja 路由(/ja/i/)。
|
||||
import InvitePage from '../../layouts/InvitePage.astro';
|
||||
---
|
||||
<InvitePage lang="ja" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ja 路由(/ja/register/)。
|
||||
import RegisterPage from '../../layouts/RegisterPage.astro';
|
||||
---
|
||||
<RegisterPage lang="ja" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ko 路由(/ko/i/)。
|
||||
import InvitePage from '../../layouts/InvitePage.astro';
|
||||
---
|
||||
<InvitePage lang="ko" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ko 路由(/ko/register/)。
|
||||
import RegisterPage from '../../layouts/RegisterPage.astro';
|
||||
---
|
||||
<RegisterPage lang="ko" />
|
||||
@@ -1,70 +1,5 @@
|
||||
---
|
||||
/**
|
||||
* register.astro — 独立注册页 `/register`(产出 dist/register/index.html)。
|
||||
*
|
||||
* 两处入口跳转到这里:① 首页 hero 免费注册表单(?email= 预填,见 SignupForm.jsx);
|
||||
* ② 邀请落地页 /i/<code> 的「注册领取 3 天」CTA(?invite= 预填,见 InviteCard.jsx)。
|
||||
* 二者共用同一套「验证码 + 设密码」注册流程(RegisterCard.jsx)。
|
||||
*
|
||||
* 页面骨架复用全站 Header/Footer + tokens/website/site-extra 样式,与 i.astro 同一路数;
|
||||
* Header/Footer 走全站 i18n 惯例整页单显中文,正文卡片(RegisterCard.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 RegisterCard from '../components/RegisterCard.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'),
|
||||
};
|
||||
// 英文根路由(/register/)。
|
||||
import RegisterPage from '../layouts/RegisterPage.astro';
|
||||
---
|
||||
<!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="创建你的穿山甲账户:一个邮箱即可开始,免费注册,无需任何付款信息。" />
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
<meta name="theme-color" content="#B96A3D" />
|
||||
</head>
|
||||
<body>
|
||||
<Header client:load lang="zh" t={headerT} />
|
||||
<main>
|
||||
<RegisterCard client:load />
|
||||
</main>
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
</html>
|
||||
<RegisterPage lang="en" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ru 路由(/ru/i/)。
|
||||
import InvitePage from '../../layouts/InvitePage.astro';
|
||||
---
|
||||
<InvitePage lang="ru" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ru 路由(/ru/register/)。
|
||||
import RegisterPage from '../../layouts/RegisterPage.astro';
|
||||
---
|
||||
<RegisterPage lang="ru" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// zh 路由(/zh/i/)。
|
||||
import InvitePage from '../../layouts/InvitePage.astro';
|
||||
---
|
||||
<InvitePage lang="zh" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// zh 路由(/zh/register/)。
|
||||
import RegisterPage from '../../layouts/RegisterPage.astro';
|
||||
---
|
||||
<RegisterPage lang="zh" />
|
||||
@@ -191,10 +191,11 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------- invite landing (/i/<code>) ----------
|
||||
/* ---------- invite landing (/i/<code> 与各语言变体) ----------
|
||||
邀请落地页正文卡片;只用现成 token(无新硬编码色),numbered-step 圆点复用
|
||||
website.css 里 .step .n 的现成样式。见 InviteCard.jsx 顶部注释:本页语言切换
|
||||
是运行时的(.langseg 胶囊开关),因为邀请码本身也只能在运行时从 URL 解析。 */
|
||||
website.css 里 .step .n 的现成样式。语言现在跟随站点路由构建期单显(见
|
||||
InviteCard.jsx 顶部注释),卡内不再有运行时切换胶囊;邀请码本身仍只能在
|
||||
运行时从 URL 解析。 */
|
||||
.invite-page {
|
||||
padding: 56px 0 88px;
|
||||
}
|
||||
@@ -210,10 +211,6 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.invite-langseg {
|
||||
align-self: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.invite-hero {
|
||||
margin-top: 8px;
|
||||
max-width: 440px;
|
||||
|
||||
Reference in New Issue
Block a user