Files
sub2api/docs/superpowers/plans/2026-04-20-portal-i18n-pricing.md
mini 623a7518b2 fix(docs): remove iShare mention (puro 独立运营定位)
- docs.sections.getKey.note 键从 zh/en 删除
- DocsView 对应 <p class="note"> 段删掉
- 全仓再次 grep 确认无其他 ishare/iShare 引用
2026-04-23 13:08:43 +08:00

1263 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PURO Portal i18n + Pricing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship bilingual (zh/en) support across 5 portal pages (Landing, Docs, Login-narrative, Register-narrative, **new** Pricing) by mounting a dark-tech `PuroLocaleSwitcher` in each top nav and extracting all hard-coded Chinese into i18n keys with English translations.
**Architecture:**
- Reuse the existing `vue-i18n` infrastructure (`setLocale()`, `availableLocales`, `sub2api_locale` localStorage key). Only add portal-specific namespaces: `landing.*`, `docs.*`, `pricing.*`, `auth.narrative.*`.
- The current admin `LocaleSwitcher.vue` uses Tailwind gray/dark palette that clashes with `.puro-page` dark-tech theme — build a new `PuroLocaleSwitcher.vue` styled with puro.css tokens (`--cyan`, `--bg-0`, `--border`, `--font-mono`), reusing the same `setLocale()` core.
- Pricing page is ported from `docs/design-drafts/v2/Pricing.html` as a fidelity Vue component, i18n-native from commit one (no "extract later" debt).
**Tech Stack:** Vue 3 + TS (`<script setup>`), vue-i18n v9 (composition API), Tailwind + scoped puro.css tokens, Vue Router 4.
**Decision log (locked before plan):**
- Pricing tiers: keep zip values (`$9.9 / $29.9 / $99`) with a `// preview · 最终定价以开售为准` header pill
- Features: port all from zip; mark any not-yet-implemented with a `SOON` chip (list resolved during Task 9)
- Binding card → links to `/register`; "联系商务" → `mailto:contact@puro.im`
- Cost calculator: zip algorithm verbatim, header pill `// estimated · 以实际计费为准`
- Nav adds `定价 / Pricing` link on Landing + Docs
---
## File Structure
**New files:**
- `frontend/src/components/puro/PuroLocaleSwitcher.vue` — dark-tech switcher
- `frontend/src/views/pricing/PricingView.vue` — new route
- `frontend/src/components/puro/PricingCalculator.vue` — cost estimator subcomponent (isolated so PricingView stays focused)
**Modified files:**
- `frontend/src/i18n/locales/zh.ts` — add `landing.*`, `docs.*`, `pricing.*`, `auth.narrative.*` namespaces
- `frontend/src/i18n/locales/en.ts` — same namespaces, English values
- `frontend/src/views/landing/LandingView.vue` — template-only, replace Chinese with `t('landing.*')`, mount switcher
- `frontend/src/views/docs/DocsView.vue` — same treatment, mount switcher, add nav link `定价`
- `frontend/src/views/auth/LoginView.vue` — narrative slot uses `t('auth.narrative.*')`
- `frontend/src/views/auth/RegisterView.vue` — same
- `frontend/src/components/layout/AuthLayout.vue` — mount switcher in a top-right floating slot
- `frontend/src/router/index.ts` — add `/pricing` route
---
## Task 0: Worktree + branch setup
**Files:**
- (workspace level — no source files changed)
- [ ] **Step 1: Create worktree and branch**
```bash
cd /Users/mini/Work/dev/sub2api
git worktree add ../sub2api-portal-i18n -b feat/portal-i18n-pricing main
cd ../sub2api-portal-i18n
```
- [ ] **Step 2: Verify clean state**
```bash
git status
git log --oneline -3
```
Expected: working tree clean, HEAD = current `main`.
- [ ] **Step 3: Install frontend deps (if worktree needs its own node_modules)**
```bash
cd frontend
pnpm install
```
Expected: install completes, no peer conflicts.
---
## Task 1: Build `PuroLocaleSwitcher` component
**Files:**
- Create: `frontend/src/components/puro/PuroLocaleSwitcher.vue`
- [ ] **Step 1: Write the component**
```vue
<template>
<div class="puro-locale" ref="dropdownRef">
<button
class="puro-locale-btn"
type="button"
:disabled="switching"
:aria-expanded="isOpen"
:title="currentLocale?.name"
@click="toggleDropdown"
>
<span class="puro-locale-code">{{ currentLocale?.code.toUpperCase() }}</span>
<svg class="puro-locale-chev" :class="{ open: isOpen }" viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<transition name="puro-locale-pop">
<div v-if="isOpen" class="puro-locale-menu" role="listbox">
<button
v-for="option in availableLocales"
:key="option.code"
type="button"
role="option"
:aria-selected="option.code === currentLocaleCode"
class="puro-locale-option"
:class="{ active: option.code === currentLocaleCode }"
:disabled="switching"
@click="selectLocale(option.code)"
>
<span class="puro-locale-option-code">{{ option.code.toUpperCase() }}</span>
<span class="puro-locale-option-name">{{ option.name }}</span>
<span v-if="option.code === currentLocaleCode" class="puro-locale-option-dot" aria-hidden="true"></span>
</button>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { setLocale, availableLocales } from '@/i18n'
const { locale } = useI18n()
const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const switching = ref(false)
const currentLocaleCode = computed(() => locale.value)
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
function toggleDropdown() {
isOpen.value = !isOpen.value
}
async function selectLocale(code: string) {
if (switching.value || code === currentLocaleCode.value) {
isOpen.value = false
return
}
switching.value = true
try {
await setLocale(code)
isOpen.value = false
} finally {
switching.value = false
}
}
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
</script>
<style scoped>
.puro-locale { position: relative; display: inline-flex; }
.puro-locale-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 12px; font-weight: 500;
color: var(--text-1, #cbd5e1);
background: rgba(2, 6, 23, 0.4);
border: 1px solid var(--border, rgba(148,163,184,0.18));
border-radius: 8px;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.puro-locale-btn:hover:not(:disabled) {
border-color: var(--border-2, rgba(148,163,184,0.32));
color: var(--cyan, #22d3ee);
}
.puro-locale-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.puro-locale-code { letter-spacing: 0.08em; }
.puro-locale-chev { color: var(--text-3, #64748b); transition: transform .15s; }
.puro-locale-chev.open { transform: rotate(180deg); color: var(--cyan, #22d3ee); }
.puro-locale-menu {
position: absolute; top: calc(100% + 6px); right: 0;
min-width: 140px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid var(--border, rgba(148,163,184,0.18));
border-radius: 10px;
padding: 4px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
backdrop-filter: blur(8px);
z-index: 50;
}
.puro-locale-option {
width: 100%;
display: flex; align-items: center; gap: 10px;
padding: 8px 10px;
background: transparent; border: none; border-radius: 6px;
font-size: 13px; color: var(--text-1, #cbd5e1);
cursor: pointer;
text-align: left;
transition: background .12s, color .12s;
}
.puro-locale-option:hover:not(:disabled) {
background: rgba(34, 211, 238, 0.08);
color: var(--text-0, #f8fafc);
}
.puro-locale-option.active {
color: var(--cyan, #22d3ee);
}
.puro-locale-option-code {
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.08em;
color: var(--text-3, #64748b);
}
.puro-locale-option.active .puro-locale-option-code { color: var(--cyan, #22d3ee); }
.puro-locale-option-name { flex: 1; }
.puro-locale-option-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--cyan, #22d3ee);
box-shadow: 0 0 0 3px rgba(34,211,238,0.2);
}
.puro-locale-pop-enter-active,
.puro-locale-pop-leave-active { transition: opacity .12s, transform .12s; }
.puro-locale-pop-enter-from,
.puro-locale-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.97); }
</style>
```
- [ ] **Step 2: Verify component typechecks**
Run: `pnpm run typecheck` (from `frontend/`)
Expected: PASS, no new errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/puro/PuroLocaleSwitcher.vue
git commit -m "feat(i18n): add PuroLocaleSwitcher for portal pages"
```
---
## Task 2: Mount switcher in `LandingView` nav
**Files:**
- Modify: `frontend/src/views/landing/LandingView.vue` (template nav block + script import; NO i18n text extraction in this task — that happens in Task 5)
- [ ] **Step 1: Import the switcher**
In `<script setup lang="ts">` block, add after existing imports:
```ts
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
```
- [ ] **Step 2: Update nav template**
Replace the existing `.nav-cta` block:
```vue
<div class="nav-cta">
<router-link to="/login" class="btn btn-ghost">登录</router-link>
<router-link to="/register" class="btn btn-primary">免费试用 </router-link>
</div>
```
With:
```vue
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">登录</router-link>
<router-link to="/register" class="btn btn-primary">免费试用 </router-link>
</div>
```
- [ ] **Step 3: Verify build**
Run: `pnpm run typecheck && pnpm run build` (from `frontend/`)
Expected: both PASS.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/views/landing/LandingView.vue
git commit -m "feat(landing): mount PuroLocaleSwitcher in nav"
```
---
## Task 3: Mount switcher in `DocsView` nav + add `定价` link
**Files:**
- Modify: `frontend/src/views/docs/DocsView.vue`
- [ ] **Step 1: Import switcher in `<script setup>`**
```ts
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
```
- [ ] **Step 2: Update `.nav-links` to add Pricing link**
Find the nav links block (currently contains `产品` / `文档`) and replace with:
```vue
<div class="nav-links">
<router-link to="/">产品</router-link>
<router-link to="/pricing">定价</router-link>
<router-link to="/docs" class="active">文档</router-link>
</div>
```
- [ ] **Step 3: Update `.nav-cta` to include switcher**
Find `.nav-cta` block and prepend `<PuroLocaleSwitcher />` before the first `router-link`:
```vue
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">登录</router-link>
<router-link to="/register" class="btn btn-primary">注册</router-link>
</div>
```
- [ ] **Step 4: Typecheck + build**
Run: `pnpm run typecheck && pnpm run build`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/views/docs/DocsView.vue
git commit -m "feat(docs): mount switcher + add pricing nav link"
```
---
## Task 4: Mount switcher in `AuthLayout` (for Login/Register)
**Files:**
- Modify: `frontend/src/components/layout/AuthLayout.vue`
The split layout already has `<slot name="narrative" />` on the left and the form on the right. Add a fixed position switcher in the top-right.
- [ ] **Step 1: Read current AuthLayout.vue to find the split container**
Run: `grep -n "auth-shell-split" /Users/mini/Work/dev/sub2api/frontend/src/components/layout/AuthLayout.vue`
Note the root element.
- [ ] **Step 2: Import switcher in `<script setup lang="ts">`**
```ts
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
```
- [ ] **Step 3: Add switcher inside split layout**
Inside `<div class="auth-shell-split" v-if="hasNarrative">` (or equivalent block), add as the first child:
```vue
<div class="auth-locale-corner">
<PuroLocaleSwitcher />
</div>
```
- [ ] **Step 4: Add scoped styles at end of `<style scoped>` block**
```css
.auth-shell-split .auth-locale-corner {
position: absolute;
top: 24px;
right: 24px;
z-index: 20;
}
```
Ensure `.auth-shell-split` has `position: relative;` — if not already set, add it to the existing rule.
- [ ] **Step 5: Typecheck + build**
Run: `pnpm run typecheck && pnpm run build`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/components/layout/AuthLayout.vue
git commit -m "feat(auth): mount switcher in split layout top-right"
```
---
## Task 5: Extract `LandingView` i18n keys + EN translation
**Files:**
- Modify: `frontend/src/views/landing/LandingView.vue` (template only)
- Modify: `frontend/src/i18n/locales/zh.ts` (add `landing` namespace)
- Modify: `frontend/src/i18n/locales/en.ts` (add `landing` namespace)
**Key naming convention:**
- `landing.nav.*` — nav links + CTAs
- `landing.hero.*` — hero eyebrow, title lines, subtitle fragments, CTAs, micro
- `landing.models.*` — section kicker, title, subtitle, card-level keys
- `landing.features.*` — section kicker, title, bullets (9 total)
- `landing.codeDemo.*` — section header + tab labels
- `landing.dashboard.*` — section header + sidebar labels + donut caption
- `landing.footer.*` — tagline + column headers + nav link labels
- [ ] **Step 1: Add `landing` namespace to `zh.ts`**
Open `frontend/src/i18n/locales/zh.ts` and append before the closing `}`:
```ts
landing: {
nav: {
products: '产品',
docs: '文档',
pricing: '定价',
login: '登录',
signup: '免费试用 →'
},
hero: {
badgeNew: 'NEW',
eyebrow: '统一接入多个 AI 平台 · 零改动切换',
title1: '你的 AI 订阅,',
title2: '已经付过钱了。',
sub1: 'Claude Pro · ChatGPT Plus · Codex · Gemini 订阅',
sub2: '聚合成统一 API零改动接入 {openai} / {anthropic} SDK',
openai: 'OpenAI',
anthropic: 'Anthropic',
ctaLogin: '登录 →',
ctaContact: '联系咨询',
micro: '已验证可用 Codex CLI · Claude Code · curl · 服务器出口新加坡'
}
// ...continue exactly for models / features / codeDemo / dashboard / footer
// Extract EVERY Chinese string present in LandingView template.
}
```
**Subagent instruction:** Read the entire `LandingView.vue` template. For each Chinese string, add a key in the `landing` namespace using the conventions above. Use `{variable}` placeholders for inline `<span>` wrappers (e.g., `pill-inline` or `text-puro-cyan` spans). Do NOT leave any Chinese in the template except brand name `PURO AI`.
- [ ] **Step 2: Mirror the namespace in `en.ts` with English translations**
Use the same key structure. Translation guidelines:
- Keep brand: `PURO AI` unchanged
- Match tone: concise, tech-product register
- Hero title: `"Your AI subscriptions, / are already paid for."`
- CTAs: `"Sign in →"`, `"Contact us"`
- Keep technical terms (`OAuth`, `API`, `SDK`, `RPM`, `Claude Code`, `Codex`) as English (they're already English in zh too)
- [ ] **Step 3: Replace Chinese in `LandingView.vue` template with `$t(...)` calls**
Example transformation:
Before:
```vue
<a href="#features">产品</a>
<a href="/docs">文档</a>
```
After:
```vue
<a href="#features">{{ $t('landing.nav.products') }}</a>
<router-link to="/docs">{{ $t('landing.nav.docs') }}</router-link>
```
For interpolated spans (e.g., `零改动接入 <span class="pill-inline">OpenAI</span> / <span class="pill-inline">Anthropic</span> SDK`), use `<i18n-t>` component:
```vue
<i18n-t keypath="landing.hero.sub2" tag="span">
<template #openai><span class="pill-inline">OpenAI</span></template>
<template #anthropic><span class="pill-inline">Anthropic</span></template>
</i18n-t>
```
- [ ] **Step 4: Verify no Chinese remains**
Run: `grep -P "[\x{4e00}-\x{9fa5}]" frontend/src/views/landing/LandingView.vue`
Expected: no results (except possibly inline `<!-- comment -->` blocks, which are fine).
- [ ] **Step 5: Typecheck + build**
Run: `pnpm run typecheck && pnpm run build`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/landing/LandingView.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts
git commit -m "feat(landing): extract i18n keys + add English translations"
```
---
## Task 6: Extract `DocsView` i18n keys + EN translation
**Files:**
- Modify: `frontend/src/views/docs/DocsView.vue`
- Modify: `frontend/src/i18n/locales/zh.ts` (add `docs` namespace)
- Modify: `frontend/src/i18n/locales/en.ts` (add `docs` namespace)
**Key naming convention:**
- `docs.nav.*` — nav link labels (reuse `landing.nav.*` where identical — but keep separate namespace for clarity and to allow divergence)
- `docs.hero.*` — page title + intro
- `docs.sections.{baseUrl,auth,models,codex,claudeCode,curl,errors,faq}.*` — each doc section: heading, paragraphs, code-block filename tabs
**Code block content:** keep code strings (curl, JSON, bash) as-is in the template — they are not translated. Only surrounding prose is extracted.
- [ ] **Step 1: Read DocsView.vue in full** to inventory all Chinese strings.
- [ ] **Step 2: Add `docs` namespace to `zh.ts`**
Use the section-based structure. Every Chinese string gets a key. Table cells (provider names, status chips `OK`/`BETA`) where the content is already English or neutral can stay as raw strings.
- [ ] **Step 3: Mirror in `en.ts`**
Translation register: technical-reference docs tone. "快速开始" → "Quickstart". "基础 URL" → "Base URL". "认证" → "Authentication". "模型列表" → "Available models". "错误码" → "Error codes". "常见问题" → "FAQ".
- [ ] **Step 4: Replace Chinese in `DocsView.vue` with `$t(...)` calls**
- [ ] **Step 5: Verify no leftover Chinese**
Run: `grep -P "[\x{4e00}-\x{9fa5}]" frontend/src/views/docs/DocsView.vue`
Expected: no results.
- [ ] **Step 6: Typecheck + build**
Run: `pnpm run typecheck && pnpm run build`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add frontend/src/views/docs/DocsView.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts
git commit -m "feat(docs): extract i18n keys + add English translations"
```
---
## Task 7: Extract auth narrative i18n keys (Login + Register) + EN
**Files:**
- Modify: `frontend/src/views/auth/LoginView.vue` (narrative slot only)
- Modify: `frontend/src/views/auth/RegisterView.vue` (narrative slot only)
- Modify: `frontend/src/i18n/locales/zh.ts` (add `auth.narrative.*`)
- Modify: `frontend/src/i18n/locales/en.ts` (same)
**Key naming convention:**
- `auth.narrative.common.*` — shared: brand, kicker, badges that appear on both
- `auth.narrative.login.*` — login-specific: `kicker = '// 你的订阅,已经付过钱了'`, headline fragments (`N 个订阅 → 1 个 key`), subtitle lines, demo panel labels, bottom status text
- `auth.narrative.register.*` — register-specific: `kicker = '// 5 分钟开始用'`, 3-step panel labels
- [ ] **Step 1: Inventory both narrative blocks**
Run: `grep -nP "[\x{4e00}-\x{9fa5}]" frontend/src/views/auth/LoginView.vue frontend/src/views/auth/RegisterView.vue`
Record each Chinese occurrence.
- [ ] **Step 2: Add `auth.narrative` keys to `zh.ts`**
Extend the existing `auth` namespace (don't replace). Example snippet:
```ts
auth: {
// ... existing keys preserved ...
narrative: {
common: {
statusLive: 'live',
brand: 'PURO AI'
},
login: {
kicker: '// 你的订阅,已经付过钱了',
headlineN: 'N',
headlineOne: '1',
headlineSep: '个订阅 →',
headlineSuffix: '个 key',
subtitle1: 'Claude Pro 的订阅 · 通过统一 API 用起来',
subtitle2: 'ChatGPT Plus 的订阅 · 无需再买官方 API',
subtitle3: 'Codex / Gemini · 一个 key 全搞定',
demoTitle: 'POST /v1/chat/completions',
bottomProviders: 'Claude · ChatGPT · Codex · Gemini',
bottomStatus: '运行中'
},
register: {
kicker: '// 5 分钟开始用',
headlineN: 'N',
headlineOne: '1',
headlineSep: '个订阅 →',
headlineSuffix: '个 key',
step1Title: '注册账号',
step1Desc: '邮箱 + 密码 · 不用信用卡',
step2Title: '绑定订阅',
step2Desc: 'OAuth 连接 Claude Pro / ChatGPT Plus',
step3Title: '拿到 API Key',
step3Desc: '替换 base_url 即可使用',
bottomProviders: 'Claude · ChatGPT · Codex · Gemini',
bottomStatus: '运行中'
}
}
}
```
- [ ] **Step 3: Mirror in `en.ts`**
Translation notes:
- `// 你的订阅,已经付过钱了``// you already paid for your subscriptions`
- `// 5 分钟开始用``// up and running in 5 minutes`
- `个订阅 →``subscriptions →`
- `个 key``key`
- Step titles: "Create account" / "Connect subscription" / "Get your API key"
- [ ] **Step 4: Replace Chinese in LoginView narrative slot**
Use `$t('auth.narrative.login.kicker')` etc. For the `N 个订阅 → 1 个 key` headline with styled `N` and `1` spans, decompose:
```vue
<h1 class="auth-headline">
<span class="num-n">{{ $t('auth.narrative.login.headlineN') }}</span>
{{ ' ' + $t('auth.narrative.login.headlineSep') + ' ' }}
<span class="num-1">{{ $t('auth.narrative.login.headlineOne') }}</span>
{{ ' ' + $t('auth.narrative.login.headlineSuffix') }}
</h1>
```
- [ ] **Step 5: Replace Chinese in RegisterView narrative slot** using the same pattern.
- [ ] **Step 6: Verify no Chinese in narrative slots**
Check only the `<template #narrative>` blocks of both files.
- [ ] **Step 7: Typecheck + build**
Run: `pnpm run typecheck && pnpm run build`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/views/auth/LoginView.vue frontend/src/views/auth/RegisterView.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts
git commit -m "feat(auth): i18n-ify narrative panels for login + register"
```
---
## Task 8: Build `PricingCalculator` subcomponent
**Files:**
- Create: `frontend/src/components/puro/PricingCalculator.vue`
Isolates the 3-slider cost estimator so `PricingView.vue` stays focused.
- [ ] **Step 1: Write component**
```vue
<template>
<div class="calc">
<div class="calc-left">
<div class="section-kicker">{{ $t('pricing.calc.kicker') }}</div>
<h3>{{ $t('pricing.calc.title') }}</h3>
<p class="sub">{{ $t('pricing.calc.sub') }}</p>
<div class="calc-controls">
<div class="slider-row">
<div class="s-top">
<span>{{ $t('pricing.calc.reqLabel') }}</span>
<span class="val">{{ reqValFormatted }}</span>
</div>
<input type="range" min="500" max="50000" step="500" v-model.number="req">
</div>
<div class="slider-row">
<div class="s-top">
<span>{{ $t('pricing.calc.tokLabel') }}</span>
<span class="val">{{ tokValFormatted }}</span>
</div>
<input type="range" min="500" max="10000" step="500" v-model.number="tok">
</div>
<div class="slider-row">
<div class="s-top">
<span>{{ $t('pricing.calc.mixLabel') }}</span>
<span class="val">{{ mix }}%</span>
</div>
<input type="range" min="0" max="100" step="10" v-model.number="mix">
</div>
</div>
</div>
<div class="calc-right">
<div class="breakdown">
<div class="line"><span class="lab">{{ $t('pricing.calc.monthlyTok') }}</span><span class="v">{{ monthlyTokFmt }}</span></div>
<div class="line"><span class="lab">{{ $t('pricing.calc.officialCost') }}</span><span class="v">${{ officialCostFmt }}</span></div>
<div class="line"><span class="lab">{{ $t('pricing.calc.puroCost') }}</span><span class="v">${{ puroCostFmt }}</span></div>
<div class="line savings"><span class="lab">{{ $t('pricing.calc.savings') }}</span><span class="v">${{ saveFmt }} · {{ savePct }}%</span></div>
</div>
<div class="total-line">
<div>
<div class="lab">{{ $t('pricing.calc.recLabel') }}</div>
<div class="rec-note">{{ recNote }}</div>
</div>
<div class="big"><span class="curr">$</span>{{ recAmt }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const req = ref(5000)
const tok = ref(3000)
const mix = ref(50)
const reqValFormatted = computed(() => req.value.toLocaleString())
const tokValFormatted = computed(() => tok.value.toLocaleString())
const monthlyTok = computed(() => req.value * tok.value * 30)
const official = computed(() => {
const avg = (mix.value / 100) * 6 + (1 - mix.value / 100) * 3
return (monthlyTok.value / 1e6) * avg
})
const puro = computed(() => official.value * 0.3)
const save = computed(() => official.value - puro.value)
const savePct = computed(() => Math.round((save.value / official.value) * 100))
function fmtNum(n: number): string {
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(0) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
return String(n)
}
function fmtMoney(n: number): string {
return Math.round(n).toLocaleString('en-US')
}
const monthlyTokFmt = computed(() => fmtNum(monthlyTok.value))
const officialCostFmt = computed(() => fmtMoney(official.value))
const puroCostFmt = computed(() => fmtMoney(puro.value))
const saveFmt = computed(() => fmtMoney(save.value))
const recAmt = computed(() => Math.ceil(puro.value))
const recNote = computed(() => {
if (puro.value < 30) return t('pricing.calc.recStarter')
if (puro.value < 80) return t('pricing.calc.recPro')
return t('pricing.calc.recScale')
})
</script>
<style scoped>
/* Styles ported verbatim from Pricing.html .calc rules */
.calc { border: 1px solid var(--border); border-radius: var(--r-xl); background: radial-gradient(600px 300px at 0% 0%, rgba(34,211,238,0.06), transparent 60%), radial-gradient(600px 300px at 100% 100%, rgba(168,85,247,0.06), transparent 60%), rgba(15, 23, 42, 0.4); padding: 32px 36px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; }
.calc-left h3 { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 8px; }
.calc-left .sub { color: var(--text-2); font-size: 14px; line-height: 1.55; margin-bottom: 22px; }
.calc-controls { display: flex; flex-direction: column; gap: 14px; }
.slider-row .s-top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; align-items: baseline; }
.slider-row .s-top .val { font-family: var(--font-mono); font-weight: 700; color: var(--cyan); }
.slider-row input[type=range] { -webkit-appearance: none; width: 100%; height: 4px; background: var(--border); border-radius: 2px; outline: none; }
.slider-row input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--cyan); cursor: pointer; box-shadow: 0 0 0 4px rgba(34,211,238,0.15); }
.calc-right { background: rgba(2, 6, 23, 0.6); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 28px; }
.calc-right .breakdown { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
.calc-right .line { display: flex; justify-content: space-between; font-size: 13px; }
.calc-right .line .lab { color: var(--text-2); }
.calc-right .line .v { font-family: var(--font-mono); color: var(--text-0); }
.calc-right .line.savings .v { color: var(--green); }
.calc-right .total-line { padding-top: 14px; border-top: 1px dashed var(--border); display: flex; justify-content: space-between; align-items: baseline; }
.calc-right .total-line .lab { font-size: 12px; color: var(--text-3); font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.14em; }
.calc-right .rec-note { font-size: 12px; color: var(--text-3); margin-top: 4px; }
.calc-right .total-line .big { font-family: var(--font-mono); font-size: 28px; font-weight: 800; color: var(--cyan); letter-spacing: -0.02em; }
.calc-right .total-line .big .curr { font-size: 14px; color: var(--text-3); font-weight: 500; margin-right: 2px; }
@media (max-width: 960px) {
.calc { grid-template-columns: 1fr; }
}
</style>
```
- [ ] **Step 2: Typecheck**
Run: `pnpm run typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/puro/PricingCalculator.vue
git commit -m "feat(pricing): add PricingCalculator subcomponent"
```
---
## Task 9: Build `PricingView` (i18n-native) + add route
**Files:**
- Create: `frontend/src/views/pricing/PricingView.vue`
- Modify: `frontend/src/router/index.ts` (add `/pricing` route)
- Modify: `frontend/src/i18n/locales/zh.ts` (add `pricing.*`)
- Modify: `frontend/src/i18n/locales/en.ts` (add `pricing.*`)
**Source:** `docs/design-drafts/v2/Pricing.html` — port verbatim, extract Chinese strings to keys.
**Decisions locked:**
- Header subkicker: ZH `// preview · 最终定价以开售为准` / EN `// preview · final pricing TBD at launch`
- Calculator header pill: ZH `// estimated · 以实际计费为准` / EN `// estimated · for reference only`
- Enterprise card → `mailto:contact@puro.im`
- Binding card → `/register` router-link (no `/binding` page)
- Tier CTAs → `/register` router-link
- Final CTA `Docs` link → `/docs`
**SOON chip:** before writing the template, the subagent audits the backend for these features (grep at `backend/`):
1. API Key monthly budget / 402 Payment Required → look for `budget` / `Payment Required`
2. Zero-log mode → look for `zero_log` / `zeroLog`
3. Priority scheduling → look for `priority`
4. RPM limits (60/120/300) → look for rate limiter
5. Subscription failover → look for `failover` / `cooling`
For each feature NOT found: wrap the `<div class="feat">` with an extra chip `<span class="soon-chip">{{ $t('pricing.soonChip') }}</span>` (chip = small pill, amber/muted). Add chip CSS in same scoped style.
- [ ] **Step 1: Audit backend for advertised features**
Run: `cd /Users/mini/Work/dev/sub2api && grep -ril -E "budget|zero_log|priority_schedul|failover|cooling" backend/ 2>/dev/null | head -20`
Document which features are implemented; the rest get `SOON` chips.
- [ ] **Step 2: Add `pricing` namespace to `zh.ts`** with structure:
```ts
pricing: {
hero: {
kicker: '// pricing · 充多少 · 用多少 · 永不过期',
previewPill: '// preview · 最终定价以开售为准',
title1: '一次充值,',
titleAccent: '全平台',
title2: '通用',
sub: '同一份积分可以用在 Claude / ChatGPT / Gemini 任意池上。我们把你的订阅额度变成真正的 API 余额 —— 相比官方 API 便宜 {discount}。',
subDiscount: '至多 70%',
underline: '余额永不过期 · 支持支付宝 / 微信 / USDT · 无隐藏订阅费'
},
soonChip: 'SOON',
tiers: {
starter: {
flag: 'STARTER',
tierLabel: 'tier · 01',
headline: '先尝尝鲜,跑通接入',
credit: '充 $9.9 → 得 {credit} 积分 {bonus}',
creditAmount: '$12',
creditBonus: '+21%',
discountTag: '相当于官方 API · 0.5 折起',
cta: '充值 →',
features: {
allModels: '可用所有模型 / 所有池',
oneKey: '{count} 个 API Key',
oneKeyCount: '1',
rpm60: '60 RPM 速率限制',
log7: '基础日志(7 天保留)',
noBYOS: '自带订阅接入',
noTeam: '团队 / 多人协作'
}
},
pro: {
flag: '◆ 推荐',
tierLabel: 'tier · 02',
headline: '个人重度用户 · 最划算',
credit: '充 $29.9 → 得 {credit} 积分 {bonus}',
creditAmount: '$45',
creditBonus: '+50%',
discountTag: '相当于官方 API · 3-7 折',
cta: '立即充值 →',
features: {
allModels: '可用所有模型 / 所有池',
threeKeys: '{count} 个 API Key · 独立预算',
threeKeysCount: '3',
rpm120: '120 RPM 速率限制',
log30: '调用日志(30 天保留)',
byos: '自带订阅接入(无限个)',
failover: '多账号 failover 调度'
}
},
scale: {
flag: '⚡ 限时 +100%',
tierLabel: 'tier · 03',
headline: '小团队 / 长跑项目',
credit: '充 $99 → 得 {credit} 积分 {bonus}',
creditAmount: '$198',
creditBonus: '+100%',
discountTag: '相当于官方 API · 2-5 折',
cta: '充值 →',
features: {
proAll: '所有 Pro 能力',
tenKeys: '{count} 个 API Key · 独立预算',
tenKeysCount: '10',
rpm300: '300 RPM 速率限制',
log90: '调用日志(90 天保留)',
priority: '请求优先级加权调度',
community: 'Slack / Discord 群组支持'
}
},
custom: {
flag: 'CUSTOM',
tierLabel: 'tier · 04',
headline: '自定义金额 · 按需充值',
creditPrefix: '得约',
bonusPrefix: '+',
discountTag: '根据金额阶梯自动匹配折扣',
cta: '定制充值 →',
features: {
noExpire: '积分永不过期',
proAll: 'Pro 全部能力',
tier: '阶梯 +21% ~ +100%',
pay: '支付宝 / 微信 / USDT',
slider: '拖动滑块预览赠送'
}
}
},
custom: {
enterprise: {
title: 'Enterprise · 企业定制',
desc: '专属订阅池、SLA、合规审计、私有化部署、发票结算。规模 >$500/月起可申请。',
cta: '联系商务 →'
},
binding: {
title: '已有订阅?直接接入',
desc: '有 Claude Max / ChatGPT Pro?免费注册后绑定,只为 PURO 路由费买单 —— 按次 {price}。',
price: '$0.0008/request',
cta: '接入我的订阅 →'
}
},
calc: {
kicker: '// cost estimator',
previewPill: '// estimated · 以实际计费为准',
title: '算算你能省多少?',
sub: '按你的使用场景,对比 PURO 和官方 API 的月度花费差。数字会根据你选的场景自动更新。',
reqLabel: '日均请求数',
tokLabel: '平均每请求 tokens',
mixLabel: 'Claude 占比',
monthlyTok: '月度 tokens 消耗',
officialCost: '官方 API 价格',
puroCost: 'PURO 价格(含 +50% 赠送)',
savings: '节省',
recLabel: '建议充值',
recStarter: '≈ Starter 档够用',
recPro: '≈ Pro 档 1 个月',
recScale: '≈ Scale 档 · 1 个月'
},
works: {
kicker: '// works everywhere',
title: '一个 key,所有工具通用',
sub: '只要支持自定义 {baseUrl} 或 OpenAI / Anthropic API,都能直接接入 PURO。',
baseUrl: 'base_url'
},
faq: {
kicker: '// frequently asked',
title: '你可能想问的',
noAnswer: '没找到答案?{contact} · 通常 2 小时内回复。',
contact: '发邮件给我们 ↗',
q1: 'PURO 和 API 中转站 / API 代理有什么不同?',
a1: '中转站只是把官方 API 请求转一手,价格取决于你预付多少 balance。PURO 的不同是 —— 我们让你 {bold}。你原本就在付的 $20/月,不再只能在官网聊天里用,而是通过统一 API 喂给 Cursor、Claude Code、任何 SDK。同时我们也提供按量充值的官方 API 备用池,两种模式可以混用。',
a1bold: '把已有的 Claude Pro / ChatGPT Plus 订阅变成 API',
q2: '用订阅跑 API 会不会被封号?',
a2: '我们会自动控制每个订阅的请求节奏,并在触发限流时把请求 failover 到池子里的其他订阅。实际上 PURO 的调用模式比你在官方客户端直接复制粘贴大段对话 {bold}。你绑定多个订阅时,单个账号的 RPM 会被压到足够安全的阈值内。另外所有凭证用 AES-256 加密存储,请求链路不经过第三方。',
a2bold: '更不容易触发风控',
q3: '积分会过期吗?可以退款吗?',
a3: '{bold}你可以攒着慢慢用 —— 包括几个月都不用。首次充值 7 天内未产生任何调用可全额退款,之后按剩余积分 85% 比例退。详见退款政策。',
a3bold: '积分永不过期。',
q4: '支持哪些支付方式?',
a4: '国内:支付宝 · 微信支付。国际:Stripe 信用卡 · USDT (TRC20 / ERC20) · PayPal。企业充值支持 Invoice 对公打款,人民币开票。',
q5: '一个 PURO 账号可以绑定多少个订阅?',
a5Starter: 'Starter 档:不支持绑定自带订阅',
a5Pro: 'Pro 档及以上:无限制,你可以把 10 个 ChatGPT Plus + 3 个 Claude Pro 一起绑上去,统一调度',
a5Enterprise: 'Enterprise:支持跨团队共享池,按组织维度隔离',
q6: '如果某个订阅触发限流了会怎样?',
a6: 'PURO 的调度器会把受限的订阅自动标记为 cooling 状态,暂时从池子里摘除。同一请求会立刻被 failover 到池内其他健康订阅上 —— 调用方通常 {bold}。你可以在 Dashboard 看到每个订阅的当前状态和剩余配额。',
a6bold: '感受不到中断',
q7: '计费精度?超量会怎么办?',
a7: '按实际 token 数 + 模型单价计费,精度到 4 位小数。每个 API Key 可设置独立月度预算,达到后 402 Payment Required,不会继续扣费。账户总余额不足时同样会返回 402,且 Dashboard 有 80% / 95% 两级提醒邮件。',
q8: '数据会被用于训练吗?',
a8: '{bold}所有请求仅用于路由转发,不入库、不留存内容(仅保留元数据如模型、token 数、延迟,用于计费和日志)。Pro 档及以上可选开启"零日志模式",我们连请求 ID 都不记录。',
a8bold: '不会。',
q9: '可以私有化部署吗?',
a9: 'Enterprise 档支持 Docker / K8s 私有化部署,控制面和数据面可以分开。授权按年订阅,包含升级和技术支持。',
q10: '支持哪些模型?会跟进新模型吗?',
a10: '当前覆盖 Claude(Sonnet 4.5 / Opus 4 / Haiku 4.5)、ChatGPT(GPT-5 / GPT-5 Codex / GPT-4.1)、Gemini(2.5 Pro / 2.5 Flash)。每当官方发布新模型,我们通常在 {bold}。完整模型列表见文档。',
a10bold: '24 小时内上线'
},
finalCta: {
kicker: '// ready to start',
title: '5 分钟,拿到你第一个 sk-puro-* key',
subtitle: '绑定你的第一个订阅即可开始。',
ctaPrimary: '免费注册 →',
ctaDocs: '查看文档'
}
}
```
**Note:** The zip's final CTA contains `注册送 $5 测试积分` — DROP this line per Stage 1 decision (no $5 bonus). The subtitle above is pruned.
- [ ] **Step 3: Mirror `pricing` namespace in `en.ts`**
Translation guidelines:
- Hero title: `"Top up once," / "unlimited across" / "all platforms"`
- Hero sub: `"The same credits work across Claude / ChatGPT / Gemini pools. We turn your subscription into real API balance — {discount} cheaper than the official API."`
- Tier CTAs: `"Top up →"` / `"Buy Pro →"` / `"Top up →"` / `"Custom top-up →"`
- Tier flags: `"STARTER"` / `"◆ RECOMMENDED"` / `"⚡ LIMITED +100%"` / `"CUSTOM"`
- FAQ answers: professional/concise tone, keep technical terms English
- [ ] **Step 4: Write `PricingView.vue`**
Structure:
```vue
<template>
<div class="puro-page">
<div class="bg-glow"></div>
<div class="grain"></div>
<!-- NAV (reuse Landing's nav pattern + /pricing active) -->
<nav class="nav">
<div class="container nav-inner">
<router-link to="/" class="brand">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" fill="rgba(34, 211, 238, 0.08)"/>
</svg>
<span>PURO AI</span>
</router-link>
<div class="nav-links">
<router-link to="/">{{ $t('landing.nav.products') }}</router-link>
<router-link to="/pricing" class="active">{{ $t('landing.nav.pricing') }}</router-link>
<router-link to="/docs">{{ $t('landing.nav.docs') }}</router-link>
</div>
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">{{ $t('landing.nav.login') }}</router-link>
<router-link to="/register" class="btn btn-primary">{{ $t('landing.nav.signup') }}</router-link>
</div>
</div>
</nav>
<!-- HERO + preview pill -->
<section class="hero">
<div class="section-kicker">{{ $t('pricing.hero.kicker') }}</div>
<div class="preview-pill">{{ $t('pricing.hero.previewPill') }}</div>
<h1>
{{ $t('pricing.hero.title1') }}<span class="accent">{{ $t('pricing.hero.titleAccent') }}</span>{{ $t('pricing.hero.title2') }}
</h1>
<i18n-t keypath="pricing.hero.sub" tag="p" class="sub">
<template #discount><b class="text-cyan">{{ $t('pricing.hero.subDiscount') }}</b></template>
</i18n-t>
<div class="underline">
<span class="dot"></span>
{{ $t('pricing.hero.underline') }}
</div>
</section>
<!-- TIER GRID: 4 cards (Starter, Pro popular, Scale, Custom with slider) -->
<!-- Each .feat that requires SOON chip per Step-1 audit gets an extra chip -->
<!-- CUSTOM ROW: Enterprise + Binding cards -->
<!-- PRICING CALCULATOR -->
<section class="calc-section">
<div class="calc-preview-pill">{{ $t('pricing.calc.previewPill') }}</div>
<PricingCalculator />
</section>
<!-- WORKS EVERYWHERE TOOLS GRID (12 tools, port SVGs verbatim) -->
<!-- FAQ (10 items, use <details>) -->
<!-- FINAL CTA (drop $5 bonus line per Stage 1 decision) -->
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
import PricingCalculator from '@/components/puro/PricingCalculator.vue'
// Custom tier slider
const customAmt = ref(50)
const customBonus = computed(() => {
const v = customAmt.value
if (v >= 500) return 120
if (v >= 200) return 110
if (v >= 99) return 100
if (v >= 50) return 70
if (v >= 30) return 50
if (v >= 20) return 35
return 21
})
const customCredit = computed(() => Math.round(customAmt.value * (1 + customBonus.value / 100)))
</script>
<style scoped>
/* Port all styles from docs/design-drafts/v2/Pricing.html <style> block verbatim.
Add: .preview-pill (amber chip, mono 10px), .soon-chip (same style), .calc-preview-pill. */
</style>
```
**Subagent execution note:** This task is large. Full template is ~400 lines of Vue. Subagent should:
1. Open `docs/design-drafts/v2/Pricing.html` in one tab
2. Open the target `PricingView.vue` being written
3. Copy each section (hero → tier grid → custom row → calc → works → faq → final-cta), replacing raw Chinese with `$t(...)` lookups per the schema in Step 2
4. Keep all SVG/HTML structure verbatim
5. Apply the SOON chip to any unimplemented feature per Step 1 audit results
6. Remove the $5 bonus line from final CTA
- [ ] **Step 5: Add `/pricing` route**
Modify `frontend/src/router/index.ts`. Add new route entry (public, no auth guard):
```ts
{
path: '/pricing',
name: 'pricing',
component: () => import('@/views/pricing/PricingView.vue'),
meta: { requiresAuth: false, title: 'Pricing · PURO AI' }
}
```
Add this near the `/docs` route (public portal section).
- [ ] **Step 6: Add `定价 / Pricing` link to LandingView nav**
Modify `LandingView.vue` `.nav-links` block:
```vue
<div class="nav-links">
<a href="#features">{{ $t('landing.nav.products') }}</a>
<router-link to="/pricing">{{ $t('landing.nav.pricing') }}</router-link>
<router-link to="/docs">{{ $t('landing.nav.docs') }}</router-link>
</div>
```
(Landing's Pricing link was added as part of Task 3's nav update for Docs; Landing gets it here.)
- [ ] **Step 7: Typecheck + build**
Run: `pnpm run typecheck && pnpm run build`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/views/pricing/ frontend/src/router/index.ts frontend/src/views/landing/LandingView.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts
git commit -m "feat(pricing): add PricingView with bilingual i18n + nav link"
```
---
## Task 10: Verification + PR + deploy
**Files:** none changed
- [ ] **Step 1: Run full typecheck + build**
```bash
cd frontend
pnpm run typecheck
pnpm run build
```
Expected: both PASS.
- [ ] **Step 2: Scan for leftover hard-coded Chinese in portal views**
```bash
cd /Users/mini/Work/dev/sub2api
grep -rnP "[\x{4e00}-\x{9fa5}]" frontend/src/views/landing/ frontend/src/views/docs/ frontend/src/views/pricing/ 2>/dev/null | grep -v "^.*://" | grep -vE "<!--.*-->"
```
Expected: empty output (only things that should remain are comments, which this grep filters).
- [ ] **Step 3: Start preview and manually verify**
```bash
cd frontend
pnpm run preview
```
Open in browser (http://localhost:4173):
- `/` — Landing: switcher in top-right, click `EN` → all text flips to English, refresh → stays English
- `/pricing` — Pricing: 4 tiers render, calculator sliders work, FAQ accordions open, switcher works
- `/docs` — Docs: tables render, copy-code works, switcher works
- `/login` — narrative panel + form, switcher top-right
- `/register` — narrative panel + form, switcher top-right
For each page: toggle EN ↔ ZH at least twice, confirm no flashes of untranslated Chinese.
- [ ] **Step 4: Stop preview**
Ctrl-C the preview server. Verify no zombie processes:
```bash
pgrep -f "vite.*preview"
```
Expected: no output. If any: `pkill -f "vite.*preview"`.
- [ ] **Step 5: Push branch and open PR**
```bash
git push -u origin feat/portal-i18n-pricing
gh pr create --title "feat: PURO portal i18n (zh/en) + Pricing page" --body "$(cat <<'EOF'
## Summary
- Puro-themed `PuroLocaleSwitcher` mounted in Landing/Docs/Login/Register top nav
- Full i18n extraction for LandingView / DocsView / Login & Register narrative panels (zh + en)
- New `/pricing` page ported from design zip, i18n-native, with preview pricing pill + SOON chips for unshipped features
- Nav adds 定价 / Pricing link on Landing + Docs
## Test plan
- [ ] Typecheck + build pass
- [ ] Toggle EN/ZH on /, /pricing, /docs, /login, /register — all text switches
- [ ] Refresh persists locale (localStorage `sub2api_locale`)
- [ ] Pricing calculator sliders update live; custom tier bonus updates
- [ ] Admin pages (`/dashboard`, `/admin/*`) unaffected — no CSS regressions
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
- [ ] **Step 6: Merge when CI green**
```bash
gh pr merge --squash --delete-branch
```
- [ ] **Step 7: Verify production deploy**
```bash
curl -sSf -o /dev/null -w "%{http_code}\n" https://ai.puro.im/
curl -sSf -o /dev/null -w "%{http_code}\n" https://ai.puro.im/pricing
curl -sSf -o /dev/null -w "%{http_code}\n" https://ai.puro.im/docs
```
Expected: `200 200 200`.
- [ ] **Step 8: Cleanup worktree**
```bash
cd /Users/mini/Work/dev/sub2api
git worktree remove ../sub2api-portal-i18n
```
---
## Appendix: Translation style guide (EN)
- **Register:** tech-product, concise, no marketing fluff
- **Technical terms:** keep English (`OAuth`, `SDK`, `API key`, `RPM`, `base_url`, `tokens`)
- **Brand tone:** PURO speaks to developers first, so answers in FAQ stay factual, not salesy
- **Punctuation:** English full stops / commas (not `,` or `。`)
- **Numbers:** keep format from zh (e.g., `$29.9`, `$198`)
## Appendix: Key guarantees
- All 5 pages continue to render correctly in zh after extraction (regression check at Task 10 Step 3)
- `setLocale()` remains the single source of truth — no custom storage added
- `PuroLocaleSwitcher` reuses `setLocale()` imports; does not duplicate i18n plumbing
- No changes to admin pages, AppHeader, or existing `LocaleSwitcher.vue` (decoupled)