up
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npm run typecheck
|
||||
- run: npm run build
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Refresh prices and deploy Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
timezone: "Asia/Shanghai"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
schedule-gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run: ${{ steps.gate.outputs.run }}
|
||||
steps:
|
||||
- id: gate
|
||||
name: Run pushes and manual dispatches immediately; schedules every two days
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" != "schedule" ]]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
epoch_day=$(( $(date -u +%s) / 86400 ))
|
||||
if (( epoch_day % 2 == 0 )); then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: schedule-gate
|
||||
if: needs.schedule-gate.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Locate the previous deployed snapshot
|
||||
shell: bash
|
||||
run: |
|
||||
owner="${GITHUB_REPOSITORY%%/*}"
|
||||
repo="${GITHUB_REPOSITORY##*/}"
|
||||
if [[ "${repo,,}" == "${owner,,}.github.io" ]]; then
|
||||
url="https://${owner}.github.io/data/prices.json"
|
||||
else
|
||||
url="https://${owner}.github.io/${repo}/data/prices.json"
|
||||
fi
|
||||
echo "PREVIOUS_SNAPSHOT_URL=$url" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Collect and validate official monthly prices
|
||||
env:
|
||||
MIN_FIRST_RUN_ROWS: 20
|
||||
run: npm run collect
|
||||
|
||||
- name: Type-check and build
|
||||
run: npm run build
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
public/data/prices.json
|
||||
coverage/
|
||||
*.local
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Business Price Radar contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Notices
|
||||
|
||||
## OpenAI and ChatGPT
|
||||
|
||||
OpenAI and ChatGPT are trademarks of their respective owners. This independent project is not affiliated with, endorsed by, or sponsored by OpenAI. Pricing data is retrieved from a publicly reachable configuration endpoint on `chatgpt.com`; availability and schema are not guaranteed.
|
||||
|
||||
## PriceAI
|
||||
|
||||
The idea of presenting regional subscription prices as a searchable comparison table was informed by publicly visible PriceAI documentation and user interface concepts. No PriceAI source code, branding, visual assets, production datasets, channel data, price snapshots, or written guide content are included in this repository.
|
||||
|
||||
PriceAI retains all rights granted or reserved under its own license, data license, and trademark policy.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Business Price Radar
|
||||
|
||||
一个独立的 ChatGPT Business 全球月付地区价格查询工具。项目从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格,并按公开汇率换算为人民币或美元。
|
||||
|
||||
> 本项目不是 OpenAI 官方产品,与 OpenAI 没有隶属、合作或背书关系。实际价格、税费、付款资格和地区可用性以结账页为准。
|
||||
|
||||
## 功能
|
||||
|
||||
- 探测完整的 ISO 3166-1 alpha-2 国家和地区代码。
|
||||
- 仅展示标准 ChatGPT Business 月付价格,不包含年付或非营利套餐。
|
||||
- 显示官方原币价格、人民币/美元估算、含税或未含税口径。
|
||||
- 支持搜索、筛选、排序以及桌面表格和移动端卡片。
|
||||
- GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。
|
||||
- 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。
|
||||
|
||||
## 本地开发
|
||||
|
||||
需要 Node.js 22 或更高版本,推荐 Node.js 24 LTS。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
本地首次打开会使用 `public/data/sample-prices.json` 演示数据。获取真实数据:
|
||||
|
||||
```bash
|
||||
npm run collect
|
||||
npm run dev
|
||||
```
|
||||
|
||||
生成的 `public/data/prices.json` 已加入 `.gitignore`,不会提交到仓库。
|
||||
|
||||
常用检查:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 数据采集规则
|
||||
|
||||
采集器位于 `scripts/collect-prices.mjs`:
|
||||
|
||||
1. 遍历 249 个 ISO 国家/地区代码。
|
||||
2. 请求 `https://chatgpt.com/backend-anon/checkout_pricing_config/configs/{CODE}`。
|
||||
3. 校验返回地区、币种和 `business.month.amount/tax`。
|
||||
4. 以 Frankfurter 为主要汇率源;未覆盖币种由 `open.er-api.com` 补齐。
|
||||
5. 通过美元中间价换算:`USD = 本币金额 ÷ USD→本币汇率`,`CNY = USD × USD→CNY汇率`。
|
||||
6. 不自行加税,只展示接口提供的税务口径。
|
||||
|
||||
采集请求不使用 Cookie、设备 ID、会话 ID、登录凭据或浏览器指纹头。
|
||||
|
||||
### 发布保护
|
||||
|
||||
- 首次快照必须包含新鲜的 US、至少一个非 USD 地区、CNY 汇率和至少 20 个有效地区。
|
||||
- 后续快照总行数不得低于上一线上快照的 90%。
|
||||
- 任何保护条件失败时,Action 直接失败,不会覆盖当前线上版本。
|
||||
|
||||
可通过环境变量调整:
|
||||
|
||||
- `PREVIOUS_SNAPSHOT_URL`:上一线上快照地址。
|
||||
- `PREVIOUS_SNAPSHOT_PATH`:本地上一快照路径。
|
||||
- `MIN_FIRST_RUN_ROWS`:首次部署最少行数,默认 `20`。
|
||||
- `OUTPUT_PATH`:输出文件,默认 `public/data/prices.json`。
|
||||
|
||||
## GitHub Pages 发布
|
||||
|
||||
1. 新建 GitHub 仓库并推送本目录内容,默认分支命名为 `main`。
|
||||
2. 打开仓库 **Settings → Pages**。
|
||||
3. 在 **Build and deployment → Source** 中选择 **GitHub Actions**。
|
||||
4. 打开 **Actions → Refresh prices and deploy Pages → Run workflow**,执行首次手动刷新。
|
||||
5. 首次成功后,网站会发布到仓库对应的 GitHub Pages 地址。
|
||||
|
||||
工作流还会在北京时间每天 03:17 唤醒,通过日期门控每两天真正刷新一次;主分支推送和手动运行会立即刷新。
|
||||
|
||||
GitHub 可能在公开仓库连续 60 天没有活动后停用定时工作流。本项目按约定不创建保活提交;如被停用,请在 Actions 页面手动重新启用并运行一次。
|
||||
|
||||
## 计费与免责声明
|
||||
|
||||
ChatGPT Business 按标准席位计费,通常至少需要两个标准席位。本站表格显示的是接口返回的**每用户月价**,不代表两席位总账单。企业税号、VAT/GST、反向征税、发卡行汇率和手续费都可能改变最终金额。
|
||||
|
||||
- [OpenAI Business 官方价格页](https://openai.com/business/pricing/)
|
||||
- [OpenAI Business 账单与席位说明](https://help.openai.com/en/articles/8792536)
|
||||
|
||||
## 授权与归属
|
||||
|
||||
代码采用 [MIT License](LICENSE)。
|
||||
|
||||
ChatGPT 与 OpenAI 是其各自权利人的商标。本项目仅作描述性使用。项目受到 PriceAI 信息架构的启发,但没有复制 PriceAI 的代码、品牌、素材、价格快照或线上数据;详见 [NOTICE.md](NOTICE.md)。
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f5f7f3" />
|
||||
<meta
|
||||
name="description"
|
||||
content="查看 ChatGPT Business 在不同国家和地区的官方月付价格,并换算为人民币或美元。"
|
||||
/>
|
||||
<meta property="og:title" content="Business Price Radar|ChatGPT Business 全球月付价格" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="独立的 ChatGPT Business 地区月价查询与汇率换算工具。"
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<title>Business Price Radar|全球月付价格</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3143
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "chatgpt-business-price-radar",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"collect": "node scripts/collect-prices.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "sample",
|
||||
"generatedAt": "2026-07-22T15:00:00.000Z",
|
||||
"pricingSource": {
|
||||
"name": "OpenAI ChatGPT checkout pricing config",
|
||||
"urlTemplate": "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/{country_code}"
|
||||
},
|
||||
"fx": {
|
||||
"baseCurrency": "USD",
|
||||
"date": "2026-06-29",
|
||||
"source": "示例汇率",
|
||||
"sourceUrls": [],
|
||||
"rates": {
|
||||
"USD": 1,
|
||||
"CNY": 6.803731,
|
||||
"SGD": 1.294141,
|
||||
"INR": 94.490194,
|
||||
"JPY": 161.766947,
|
||||
"EUR": 0.878247,
|
||||
"BRL": 5.173169
|
||||
}
|
||||
},
|
||||
"coverage": {
|
||||
"requested": 249,
|
||||
"success": 6,
|
||||
"unsupported": 0,
|
||||
"failed": 0,
|
||||
"stale": 0
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"countryCode": "JP",
|
||||
"countryName": "日本",
|
||||
"currencyCode": "JPY",
|
||||
"symbol": "¥",
|
||||
"minorUnitExponent": 0,
|
||||
"localAmount": 3850,
|
||||
"usdAmount": 23.8,
|
||||
"cnyAmount": 161.93,
|
||||
"taxTreatment": "exclusive",
|
||||
"taxType": "jct",
|
||||
"taxPercent": 10,
|
||||
"status": "fresh",
|
||||
"fetchedAt": "2026-07-22T15:00:00.000Z",
|
||||
"sourceUrl": "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/JP"
|
||||
},
|
||||
{
|
||||
"countryCode": "IN",
|
||||
"countryName": "印度",
|
||||
"currencyCode": "INR",
|
||||
"symbol": "₹",
|
||||
"minorUnitExponent": 2,
|
||||
"localAmount": 2250,
|
||||
"usdAmount": 23.81,
|
||||
"cnyAmount": 162.01,
|
||||
"taxTreatment": "exclusive",
|
||||
"taxType": "gst",
|
||||
"taxPercent": 18,
|
||||
"status": "fresh",
|
||||
"fetchedAt": "2026-07-22T15:00:00.000Z",
|
||||
"sourceUrl": "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/IN"
|
||||
},
|
||||
{
|
||||
"countryCode": "SG",
|
||||
"countryName": "新加坡",
|
||||
"currencyCode": "SGD",
|
||||
"symbol": "S$",
|
||||
"minorUnitExponent": 2,
|
||||
"localAmount": 32,
|
||||
"usdAmount": 24.73,
|
||||
"cnyAmount": 168.23,
|
||||
"taxTreatment": "exclusive",
|
||||
"taxType": "gst",
|
||||
"taxPercent": 9,
|
||||
"status": "fresh",
|
||||
"fetchedAt": "2026-07-22T15:00:00.000Z",
|
||||
"sourceUrl": "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/SG"
|
||||
},
|
||||
{
|
||||
"countryCode": "US",
|
||||
"countryName": "美国",
|
||||
"currencyCode": "USD",
|
||||
"symbol": "$",
|
||||
"minorUnitExponent": 2,
|
||||
"localAmount": 25,
|
||||
"usdAmount": 25,
|
||||
"cnyAmount": 170.09,
|
||||
"taxTreatment": "exclusive",
|
||||
"taxType": "other",
|
||||
"taxPercent": null,
|
||||
"status": "fresh",
|
||||
"fetchedAt": "2026-07-22T15:00:00.000Z",
|
||||
"sourceUrl": "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/US"
|
||||
},
|
||||
{
|
||||
"countryCode": "BR",
|
||||
"countryName": "巴西",
|
||||
"currencyCode": "BRL",
|
||||
"symbol": "R$",
|
||||
"minorUnitExponent": 2,
|
||||
"localAmount": 130,
|
||||
"usdAmount": 25.13,
|
||||
"cnyAmount": 170.98,
|
||||
"taxTreatment": "exclusive",
|
||||
"taxType": "vat",
|
||||
"taxPercent": 0,
|
||||
"status": "fresh",
|
||||
"fetchedAt": "2026-07-22T15:00:00.000Z",
|
||||
"sourceUrl": "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/BR"
|
||||
},
|
||||
{
|
||||
"countryCode": "DE",
|
||||
"countryName": "德国",
|
||||
"currencyCode": "EUR",
|
||||
"symbol": "€",
|
||||
"minorUnitExponent": 2,
|
||||
"localAmount": 26,
|
||||
"usdAmount": 29.6,
|
||||
"cnyAmount": 201.42,
|
||||
"taxTreatment": "inclusive",
|
||||
"taxType": "vat",
|
||||
"taxPercent": 19,
|
||||
"status": "fresh",
|
||||
"fetchedAt": "2026-07-22T15:00:00.000Z",
|
||||
"sourceUrl": "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/DE"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { z } from "zod";
|
||||
|
||||
export const ISO_COUNTRY_CODES = `
|
||||
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
|
||||
BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
|
||||
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
|
||||
DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
|
||||
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
|
||||
HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP
|
||||
KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY
|
||||
MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
|
||||
NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY
|
||||
QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
|
||||
TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ
|
||||
VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
|
||||
`.trim().split(/\s+/).sort();
|
||||
|
||||
export const PRICING_URL_TEMPLATE = "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/{country_code}";
|
||||
const FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest";
|
||||
const OPEN_EXCHANGE_URL = "https://open.er-api.com/v6/latest/USD";
|
||||
const DEFAULT_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_RETRIES = 3;
|
||||
const DEFAULT_CONCURRENCY = 6;
|
||||
const STALE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const upstreamSchema = z.object({
|
||||
country_code: z.string().length(2),
|
||||
currency_config: z.object({
|
||||
business: z.object({
|
||||
month: z.object({
|
||||
amount: z.number().positive().finite(),
|
||||
tax: z.enum(["inclusive", "exclusive"]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
symbol_code: z.string().length(3),
|
||||
symbol: z.string().min(1),
|
||||
minor_unit_exponent: z.number().int().min(0).max(4).optional().default(2),
|
||||
tax_type: z.string().min(1).nullable().optional(),
|
||||
tax_percent: z.number().finite().nullable().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const snapshotSchema = z.object({
|
||||
version: z.literal(1),
|
||||
generatedAt: z.string(),
|
||||
rows: z.array(z.object({
|
||||
countryCode: z.string().length(2),
|
||||
countryName: z.string(),
|
||||
currencyCode: z.string().length(3),
|
||||
symbol: z.string(),
|
||||
minorUnitExponent: z.number().int(),
|
||||
localAmount: z.number(),
|
||||
usdAmount: z.number(),
|
||||
cnyAmount: z.number(),
|
||||
taxTreatment: z.enum(["inclusive", "exclusive"]),
|
||||
taxType: z.string().nullable(),
|
||||
taxPercent: z.number().nullable(),
|
||||
status: z.enum(["fresh", "stale"]),
|
||||
fetchedAt: z.string(),
|
||||
sourceUrl: z.string().url(),
|
||||
})),
|
||||
}).passthrough();
|
||||
|
||||
const countryNames = new Intl.DisplayNames(["zh-CN"], { type: "region" });
|
||||
|
||||
export function sourceUrl(countryCode) {
|
||||
return PRICING_URL_TEMPLATE.replace("{country_code}", countryCode.toUpperCase());
|
||||
}
|
||||
|
||||
export function parsePriceResponse(raw, requestedCode, fetchedAt = new Date().toISOString()) {
|
||||
const data = upstreamSchema.parse(raw);
|
||||
const countryCode = requestedCode.toUpperCase();
|
||||
if (data.country_code.toUpperCase() !== countryCode) {
|
||||
throw new Error(`Country mismatch: requested ${countryCode}, received ${data.country_code}`);
|
||||
}
|
||||
|
||||
return {
|
||||
countryCode,
|
||||
countryName: countryNames.of(countryCode) || countryCode,
|
||||
currencyCode: data.symbol_code.toUpperCase(),
|
||||
symbol: data.symbol,
|
||||
minorUnitExponent: data.minor_unit_exponent,
|
||||
localAmount: data.currency_config.business.month.amount,
|
||||
taxTreatment: data.currency_config.business.month.tax,
|
||||
taxType: data.tax_type ?? null,
|
||||
taxPercent: data.tax_percent ?? null,
|
||||
status: "fresh",
|
||||
fetchedAt,
|
||||
sourceUrl: sourceUrl(countryCode),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertAmount(localAmount, currencyCode, rates) {
|
||||
const localRate = currencyCode === "USD" ? 1 : Number(rates[currencyCode]);
|
||||
const cnyRate = Number(rates.CNY);
|
||||
if (!Number.isFinite(localRate) || localRate <= 0) throw new Error(`Missing FX rate for ${currencyCode}`);
|
||||
if (!Number.isFinite(cnyRate) || cnyRate <= 0) throw new Error("Missing FX rate for CNY");
|
||||
const rawUsd = localAmount / localRate;
|
||||
return {
|
||||
usdAmount: roundMoney(rawUsd),
|
||||
cnyAmount: roundMoney(rawUsd * cnyRate),
|
||||
};
|
||||
}
|
||||
|
||||
export function withConvertedAmounts(row, rates) {
|
||||
return { ...row, ...convertAmount(row.localAmount, row.currencyCode, rates) };
|
||||
}
|
||||
|
||||
export function mergeStaleRows(freshRows, failedCodes, previousRows, rates, now = new Date()) {
|
||||
const freshCodes = new Set(freshRows.map((row) => row.countryCode));
|
||||
const failed = new Set(failedCodes);
|
||||
const staleRows = previousRows
|
||||
.filter((row) => failed.has(row.countryCode) && !freshCodes.has(row.countryCode))
|
||||
.filter((row) => now.getTime() - new Date(row.fetchedAt).getTime() <= STALE_MAX_AGE_MS)
|
||||
.map((row) => withConvertedAmounts({ ...row, status: "stale" }, rates));
|
||||
return [...freshRows, ...staleRows];
|
||||
}
|
||||
|
||||
export function validateCoverage(snapshot, previousSnapshot = null, minimumFirstRunRows = 20) {
|
||||
const freshUs = snapshot.rows.find((row) => row.countryCode === "US" && row.status === "fresh");
|
||||
if (!freshUs) throw new Error("Coverage guard failed: a fresh US Business price is required.");
|
||||
const freshNonUsd = snapshot.rows.find((row) => row.currencyCode !== "USD" && row.status === "fresh");
|
||||
if (!freshNonUsd) throw new Error("Coverage guard failed: a fresh non-USD Business price is required.");
|
||||
if (!Number.isFinite(Number(snapshot.fx.rates.CNY))) throw new Error("Coverage guard failed: CNY FX rate is required.");
|
||||
|
||||
if (previousSnapshot?.rows?.length) {
|
||||
const minimum = Math.ceil(previousSnapshot.rows.length * 0.9);
|
||||
if (snapshot.rows.length < minimum) {
|
||||
throw new Error(`Coverage guard failed: ${snapshot.rows.length} rows is below 90% of previous ${previousSnapshot.rows.length}.`);
|
||||
}
|
||||
} else if (snapshot.rows.length < minimumFirstRunRows) {
|
||||
throw new Error(`Coverage guard failed: first live snapshot requires at least ${minimumFirstRunRows} rows.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"user-agent": "BusinessPriceRadar/1.0 (public pricing reference)",
|
||||
...options.headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJsonWithRetry(url, options = {}) {
|
||||
const retries = options.retries ?? DEFAULT_RETRIES;
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < retries; attempt += 1) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
if (response.status === 404) return { kind: "unsupported", status: 404 };
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status} for ${url}`);
|
||||
if (response.status < 500 && response.status !== 429) throw Object.assign(error, { nonRetryable: true });
|
||||
throw error;
|
||||
}
|
||||
return { kind: "success", data: await response.json(), status: response.status };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error?.nonRetryable || attempt === retries - 1) break;
|
||||
await sleep((attempt + 1) * 850 + Math.floor(Math.random() * 250));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function collectCountry(countryCode, fetchedAt) {
|
||||
const result = await fetchJsonWithRetry(sourceUrl(countryCode));
|
||||
if (result.kind === "unsupported") return { kind: "unsupported", countryCode };
|
||||
return { kind: "success", countryCode, row: parsePriceResponse(result.data, countryCode, fetchedAt) };
|
||||
}
|
||||
|
||||
async function collectAllCountries(countryCodes, fetchedAt, concurrency = DEFAULT_CONCURRENCY) {
|
||||
const results = await mapLimit(countryCodes, concurrency, async (countryCode) => {
|
||||
try {
|
||||
return await collectCountry(countryCode, fetchedAt);
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "failed",
|
||||
countryCode,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
rows: results.filter((item) => item.kind === "success").map((item) => item.row),
|
||||
unsupportedCodes: results.filter((item) => item.kind === "unsupported").map((item) => item.countryCode),
|
||||
failedItems: results.filter((item) => item.kind === "failed"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchFxSnapshot(currencies) {
|
||||
const wanted = [...new Set(["CNY", ...currencies.filter((item) => item !== "USD")])].sort();
|
||||
const rates = { USD: 1 };
|
||||
const sourceUrls = [];
|
||||
let primaryDate = null;
|
||||
let primaryWorked = false;
|
||||
|
||||
const primaryUrl = `${FRANKFURTER_URL}?base=USD&symbols=${encodeURIComponent(wanted.join(","))}`;
|
||||
sourceUrls.push(primaryUrl);
|
||||
try {
|
||||
const result = await fetchJsonWithRetry(primaryUrl, { retries: 2 });
|
||||
if (result.kind === "success") {
|
||||
Object.assign(rates, numericRates(result.data.rates));
|
||||
primaryDate = result.data.date || null;
|
||||
primaryWorked = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Frankfurter unavailable: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
|
||||
const missing = wanted.filter((currency) => !Number.isFinite(Number(rates[currency])));
|
||||
let fallbackDate = null;
|
||||
if (missing.length) {
|
||||
sourceUrls.push(OPEN_EXCHANGE_URL);
|
||||
const fallback = await fetchJsonWithRetry(OPEN_EXCHANGE_URL);
|
||||
if (fallback.kind !== "success") throw new Error("FX fallback unexpectedly returned no data.");
|
||||
const fallbackRates = numericRates(fallback.data.rates);
|
||||
for (const currency of missing) {
|
||||
if (Number.isFinite(fallbackRates[currency])) rates[currency] = fallbackRates[currency];
|
||||
}
|
||||
fallbackDate = dateFromOpenExchange(fallback.data.time_last_update_utc);
|
||||
}
|
||||
|
||||
const stillMissing = wanted.filter((currency) => !Number.isFinite(Number(rates[currency])));
|
||||
if (stillMissing.length) throw new Error(`Missing FX rates for ${stillMissing.join(", ")}`);
|
||||
|
||||
return {
|
||||
baseCurrency: "USD",
|
||||
date: primaryDate || fallbackDate || new Date().toISOString().slice(0, 10),
|
||||
source: missing.length ? (primaryWorked ? "Frankfurter + open.er-api.com" : "open.er-api.com") : "Frankfurter",
|
||||
sourceUrls,
|
||||
rates,
|
||||
};
|
||||
}
|
||||
|
||||
function numericRates(value) {
|
||||
if (!value || typeof value !== "object") return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([currency, rate]) => [currency, Number(rate)])
|
||||
.filter(([, rate]) => Number.isFinite(rate) && rate > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function dateFromOpenExchange(value) {
|
||||
const date = new Date(value || "");
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function readPreviousSnapshot(urlOrPath) {
|
||||
if (!urlOrPath) return null;
|
||||
try {
|
||||
let raw;
|
||||
if (/^https?:\/\//i.test(urlOrPath)) {
|
||||
const result = await fetchJsonWithRetry(urlOrPath, { retries: 1, timeoutMs: 8_000 });
|
||||
if (result.kind !== "success") return null;
|
||||
raw = result.data;
|
||||
} else {
|
||||
raw = JSON.parse(await readFile(urlOrPath, "utf8"));
|
||||
}
|
||||
return snapshotSchema.parse(raw);
|
||||
} catch (error) {
|
||||
console.warn(`Previous snapshot unavailable: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectSnapshot(options = {}) {
|
||||
const generatedAt = options.generatedAt || new Date().toISOString();
|
||||
const countryCodes = options.countryCodes || ISO_COUNTRY_CODES;
|
||||
const previousSnapshot = options.previousSnapshot || null;
|
||||
const collected = await collectAllCountries(countryCodes, generatedAt, options.concurrency);
|
||||
const currencies = collected.rows.map((row) => row.currencyCode);
|
||||
for (const failed of collected.failedItems) {
|
||||
const previous = previousSnapshot?.rows?.find((row) => row.countryCode === failed.countryCode);
|
||||
if (previous) currencies.push(previous.currencyCode);
|
||||
}
|
||||
const fx = await fetchFxSnapshot(currencies);
|
||||
const freshRows = collected.rows.map((row) => withConvertedAmounts(row, fx.rates));
|
||||
const rows = mergeStaleRows(
|
||||
freshRows,
|
||||
collected.failedItems.map((item) => item.countryCode),
|
||||
previousSnapshot?.rows || [],
|
||||
fx.rates,
|
||||
new Date(generatedAt),
|
||||
).sort((a, b) => a.cnyAmount - b.cnyAmount);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
mode: "live",
|
||||
generatedAt,
|
||||
pricingSource: {
|
||||
name: "OpenAI ChatGPT checkout pricing config",
|
||||
urlTemplate: PRICING_URL_TEMPLATE,
|
||||
},
|
||||
fx,
|
||||
coverage: {
|
||||
requested: countryCodes.length,
|
||||
success: freshRows.length,
|
||||
unsupported: collected.unsupportedCodes.length,
|
||||
failed: collected.failedItems.length,
|
||||
stale: rows.filter((row) => row.status === "stale").length,
|
||||
},
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function runCli() {
|
||||
const outputPath = cliValue("--output") || process.env.OUTPUT_PATH || "public/data/prices.json";
|
||||
const previousSource = process.env.PREVIOUS_SNAPSHOT_URL || process.env.PREVIOUS_SNAPSHOT_PATH || null;
|
||||
const previousSnapshot = await readPreviousSnapshot(previousSource);
|
||||
const snapshot = await collectSnapshot({ previousSnapshot });
|
||||
validateCoverage(snapshot, previousSnapshot, Number(process.env.MIN_FIRST_RUN_ROWS || 20));
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify({ outputPath, generatedAt: snapshot.generatedAt, coverage: snapshot.coverage, fx: snapshot.fx.source }, null, 2));
|
||||
}
|
||||
|
||||
function cliValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : null;
|
||||
}
|
||||
|
||||
async function mapLimit(items, limit, mapper) {
|
||||
const output = new Array(items.length);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < items.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
output[index] = await mapper(items[index], index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
||||
return output;
|
||||
}
|
||||
|
||||
function roundMoney(value) {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||
if (isMain) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import sample from "../public/data/sample-prices.json";
|
||||
import App from "./App";
|
||||
|
||||
describe("pricing explorer", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.stubGlobal("fetch", vi.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => sample }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads the snapshot, preserves local prices and switches display currency locally", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
|
||||
expect((await screen.findAllByText("S$32.00")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("button", { name: "$ USD" })).toHaveAttribute("aria-pressed", "false");
|
||||
await user.click(screen.getByRole("button", { name: "$ USD" }));
|
||||
expect(screen.getByRole("button", { name: "$ USD" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect((await screen.findAllByText("$23.80")).length).toBeGreaterThan(0);
|
||||
expect((screen.getAllByText("S$32.00")).length).toBeGreaterThan(0);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("filters by country and tax treatment", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findAllByText("美国");
|
||||
await user.type(screen.getByPlaceholderText("搜索国家、代码或币种"), "新加坡");
|
||||
await waitFor(() => expect(screen.queryByText("德国")).not.toBeInTheDocument());
|
||||
expect(screen.getAllByText("新加坡").length).toBeGreaterThan(0);
|
||||
await user.clear(screen.getByPlaceholderText("搜索国家、代码或币种"));
|
||||
await user.selectOptions(screen.getByLabelText("税费"), "inclusive");
|
||||
expect(screen.getAllByText("德国").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("新加坡")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
ArrowUpRight,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
Database,
|
||||
Info,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { filterAndSortRows, flagEmoji, formatConverted, formatLocal, relativeTime, taxLabel } from "./lib";
|
||||
import type {
|
||||
DisplayCurrency,
|
||||
PriceRow,
|
||||
PricingSnapshot,
|
||||
SortMode,
|
||||
StatusFilter,
|
||||
TaxFilter,
|
||||
} from "./types";
|
||||
|
||||
const currencyStorageKey = "business-price-radar:currency";
|
||||
|
||||
async function loadSnapshot(): Promise<PricingSnapshot> {
|
||||
const liveUrl = new URL("data/prices.json", document.baseURI);
|
||||
const sampleUrl = new URL("data/sample-prices.json", document.baseURI);
|
||||
|
||||
const liveResponse = await fetch(liveUrl, { cache: "no-store" });
|
||||
if (liveResponse.ok) {
|
||||
const contentType = liveResponse.headers?.get?.("content-type") || "application/json";
|
||||
if (contentType.includes("json")) {
|
||||
try {
|
||||
return await liveResponse.json() as PricingSnapshot;
|
||||
} catch {
|
||||
// Vite and some static hosts may return index.html for a missing JSON file.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sampleResponse = await fetch(sampleUrl, { cache: "no-store" });
|
||||
if (!sampleResponse.ok) throw new Error("价格快照暂时不可用");
|
||||
return sampleResponse.json() as Promise<PricingSnapshot>;
|
||||
}
|
||||
|
||||
function initialCurrency(): DisplayCurrency {
|
||||
const stored = window.localStorage.getItem(currencyStorageKey);
|
||||
return stored === "USD" ? "USD" : "CNY";
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [snapshot, setSnapshot] = useState<PricingSnapshot | null>(null);
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null);
|
||||
const [currency, setCurrency] = useState<DisplayCurrency>(initialCurrency);
|
||||
const [query, setQuery] = useState("");
|
||||
const [tax, setTax] = useState<TaxFilter>("all");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [sort, setSort] = useState<SortMode>("converted");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
loadSnapshot()
|
||||
.then((data) => {
|
||||
if (active) setSnapshot(data);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (active) setLoadingError(error instanceof Error ? error.message : "价格快照暂时不可用");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(currencyStorageKey, currency);
|
||||
}, [currency]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
filterAndSortRows(snapshot?.rows ?? [], {
|
||||
query,
|
||||
tax,
|
||||
status,
|
||||
sort,
|
||||
currency,
|
||||
}),
|
||||
[snapshot, query, tax, status, sort, currency],
|
||||
);
|
||||
|
||||
const lowest = useMemo(() => {
|
||||
if (!snapshot?.rows.length) return null;
|
||||
return [...snapshot.rows].sort((a, b) => {
|
||||
const aValue = currency === "CNY" ? a.cnyAmount : a.usdAmount;
|
||||
const bValue = currency === "CNY" ? b.cnyAmount : b.usdAmount;
|
||||
return aValue - bValue;
|
||||
})[0];
|
||||
}, [snapshot, currency]);
|
||||
|
||||
const setDisplayCurrency = (value: DisplayCurrency) => {
|
||||
setCurrency(value);
|
||||
window.localStorage.setItem(currencyStorageKey, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<a className="brand" href="./" aria-label="Business Price Radar 首页">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
<span />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Business Price Radar</strong>
|
||||
<small>全球月付价格</small>
|
||||
</span>
|
||||
</a>
|
||||
<div className="topbar-actions">
|
||||
<span className="source-pill">
|
||||
<span className="live-dot" /> OpenAI 公开配置
|
||||
</span>
|
||||
<CurrencyToggle value={currency} onChange={setDisplayCurrency} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="hero">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow"><CircleDollarSign size={15} /> CHATGPT BUSINESS · MONTHLY</span>
|
||||
<h1>一眼看懂,<br /><em>全球月付差多少。</em></h1>
|
||||
<p>
|
||||
汇总不同国家和地区的 ChatGPT Business 官方月付配置,保留原币价格,统一换算成人民币或美元。
|
||||
</p>
|
||||
<div className="hero-notes">
|
||||
<span><Users size={16} /> 每用户 / 月</span>
|
||||
<span><ShieldCheck size={16} /> 原价税务口径</span>
|
||||
<span><CalendarClock size={16} /> 每两天更新</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-card">
|
||||
<div className="hero-card-top">
|
||||
<span>当前最低月付</span>
|
||||
<span className="rank-badge">全球 #1</span>
|
||||
</div>
|
||||
{lowest ? (
|
||||
<>
|
||||
<div className="lowest-country">
|
||||
<span className="large-flag">{flagEmoji(lowest.countryCode)}</span>
|
||||
<div>
|
||||
<strong>{lowest.countryName}</strong>
|
||||
<span>{lowest.countryCode} · {lowest.currencyCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lowest-price">
|
||||
{formatConverted(currency === "CNY" ? lowest.cnyAmount : lowest.usdAmount, currency)}
|
||||
<small>/ 用户 / 月</small>
|
||||
</div>
|
||||
<div className="local-price-line">
|
||||
官方原价 <strong>{formatLocal(lowest)}</strong>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="hero-skeleton" aria-label="正在加载最低价格" />
|
||||
)}
|
||||
<div className="hero-card-foot">
|
||||
<CheckCircle2 size={15} /> 价格按接口返回口径比较,未自行加税
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{snapshot?.mode === "sample" ? (
|
||||
<div className="sample-banner" role="status">
|
||||
<Info size={17} /> 当前为本地演示快照;GitHub Actions 首次成功部署后将自动替换为全量实时数据。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loadingError ? (
|
||||
<section className="error-state" role="alert">
|
||||
<Database size={28} />
|
||||
<div><strong>暂时无法读取价格快照</strong><span>{loadingError}</span></div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="metrics" aria-label="数据概览">
|
||||
<Metric
|
||||
icon={<Database size={20} />}
|
||||
label="有效地区"
|
||||
value={snapshot ? String(snapshot.coverage.success + snapshot.coverage.stale) : "—"}
|
||||
detail={snapshot ? `探测 ${snapshot.coverage.requested} 个 ISO 地区` : "正在读取快照"}
|
||||
/>
|
||||
<Metric
|
||||
icon={<CircleDollarSign size={20} />}
|
||||
label="显示币种"
|
||||
value={currency}
|
||||
detail={currency === "CNY" ? "按美元中间价折算人民币" : "统一折算为美元"}
|
||||
/>
|
||||
<Metric
|
||||
icon={<CalendarClock size={20} />}
|
||||
label="数据更新时间"
|
||||
value={snapshot ? relativeTime(snapshot.generatedAt) : "—"}
|
||||
detail={snapshot ? `汇率日期 ${snapshot.fx.date}` : "等待数据"}
|
||||
/>
|
||||
<Metric
|
||||
icon={<ShieldCheck size={20} />}
|
||||
label="暂旧数据"
|
||||
value={snapshot ? String(snapshot.coverage.stale) : "—"}
|
||||
detail="最多沿用 14 天"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="explorer" aria-labelledby="price-list-title">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">REGIONAL PRICING</span>
|
||||
<h2 id="price-list-title">地区月付价格</h2>
|
||||
</div>
|
||||
<p>共显示 <strong>{rows.length}</strong> 个地区</p>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<label className="search-control">
|
||||
<Search size={18} />
|
||||
<span className="sr-only">搜索地区</span>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索国家、代码或币种" />
|
||||
</label>
|
||||
<label className="select-control">
|
||||
<span>税费</span>
|
||||
<select value={tax} onChange={(event) => setTax(event.target.value as TaxFilter)}>
|
||||
<option value="all">全部口径</option>
|
||||
<option value="inclusive">含税</option>
|
||||
<option value="exclusive">未含税</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="select-control">
|
||||
<span>状态</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="fresh">最新</option>
|
||||
<option value="stale">暂旧</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="select-control sort-control">
|
||||
<ArrowDownUp size={16} />
|
||||
<span className="sr-only">排序</span>
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value as SortMode)}>
|
||||
<option value="converted">折合价格最低</option>
|
||||
<option value="country">国家名称</option>
|
||||
<option value="local">原币数字</option>
|
||||
<option value="updated">最近更新</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!snapshot && !loadingError ? <LoadingRows /> : null}
|
||||
{snapshot && rows.length ? (
|
||||
<>
|
||||
<PriceTable rows={rows} currency={currency} />
|
||||
<PriceCards rows={rows} currency={currency} />
|
||||
</>
|
||||
) : null}
|
||||
{snapshot && !rows.length ? (
|
||||
<div className="empty-state"><Search size={24} /><span>没有符合当前筛选条件的地区</span></div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="explainers">
|
||||
<article>
|
||||
<span className="explainer-number">01</span>
|
||||
<h3>价格从哪里来?</h3>
|
||||
<p>报价来自 chatgpt.com 的公开结账价格配置接口。本站只读取 Business 月付字段,不参与购买。</p>
|
||||
</article>
|
||||
<article>
|
||||
<span className="explainer-number">02</span>
|
||||
<h3>为什么金额会不同?</h3>
|
||||
<p>OpenAI 会按地区设置本地币种和价格。汇率换算只用于横向比较,不等于发卡行最终结算价。</p>
|
||||
</article>
|
||||
<article>
|
||||
<span className="explainer-number">03</span>
|
||||
<h3>税费如何处理?</h3>
|
||||
<p>不自行加税,直接展示接口的含税或未含税标记。企业税号、VAT 与反向征税可能改变账单。</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="disclaimer">
|
||||
<Info size={19} />
|
||||
<p>
|
||||
<strong>独立信息工具,非 OpenAI 官方产品。</strong>
|
||||
实际价格、税费、付款资格与地区可用性以结账页为准。ChatGPT Business 按用户计费,标准席位至少购买 2 个。
|
||||
<a href="https://help.openai.com/en/articles/8792536" target="_blank" rel="noreferrer">查看官方账单说明 <ArrowUpRight size={14} /></a>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div className="brand footer-brand">
|
||||
<span className="brand-mark" aria-hidden="true"><span /></span>
|
||||
<span><strong>Business Price Radar</strong><small>Independent pricing reference</small></span>
|
||||
</div>
|
||||
<p>ChatGPT 与 OpenAI 为其各自权利人的商标。本站与 OpenAI 无隶属或背书关系。</p>
|
||||
<span>MIT License · Data refreshed by GitHub Actions</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrencyToggle({ value, onChange }: { value: DisplayCurrency; onChange: (value: DisplayCurrency) => void }) {
|
||||
return (
|
||||
<div className="currency-toggle" aria-label="显示币种">
|
||||
{(["CNY", "USD"] as const).map((currency) => (
|
||||
<button
|
||||
key={currency}
|
||||
type="button"
|
||||
aria-pressed={value === currency}
|
||||
className={value === currency ? "active" : ""}
|
||||
onClick={() => onChange(currency)}
|
||||
>
|
||||
{currency === "CNY" ? "¥ CNY" : "$ USD"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<article className="metric-card">
|
||||
<div className="metric-icon">{icon}</div>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{detail}</small>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ConvertedPrice({ row, currency }: { row: PriceRow; currency: DisplayCurrency }) {
|
||||
const amount = currency === "CNY" ? row.cnyAmount : row.usdAmount;
|
||||
return <>{formatConverted(amount, currency)}</>;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: PriceRow["status"] }) {
|
||||
return status === "fresh" ? (
|
||||
<span className="status-badge fresh"><span /> 最新</span>
|
||||
) : (
|
||||
<span className="status-badge stale"><span /> 数据暂旧</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceTable({ rows, currency }: { rows: PriceRow[]; currency: DisplayCurrency }) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">地区</th>
|
||||
<th scope="col">官方原币月价</th>
|
||||
<th scope="col">折合 {currency}</th>
|
||||
<th scope="col">税费口径</th>
|
||||
<th scope="col">更新时间</th>
|
||||
<th scope="col"><span className="sr-only">来源</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.countryCode}>
|
||||
<td>
|
||||
<div className="country-cell">
|
||||
<span className="rank">{String(index + 1).padStart(2, "0")}</span>
|
||||
<span className="flag">{flagEmoji(row.countryCode)}</span>
|
||||
<div><strong>{row.countryName}</strong><span>{row.countryCode} · {row.currencyCode}</span></div>
|
||||
</div>
|
||||
</td>
|
||||
<td><strong className="local-amount">{formatLocal(row)}</strong><span className="per-user">/ 用户 / 月</span></td>
|
||||
<td><strong className="converted-amount"><ConvertedPrice row={row} currency={currency} /></strong></td>
|
||||
<td><span className={`tax-badge ${row.taxTreatment}`}>{taxLabel(row)}</span></td>
|
||||
<td><StatusBadge status={row.status} /><span className="updated-time">{relativeTime(row.fetchedAt)}</span></td>
|
||||
<td>
|
||||
<a className="source-link" href={row.sourceUrl} target="_blank" rel="noreferrer" aria-label={`查看${row.countryName}官方价格源`}>
|
||||
<ArrowUpRight size={17} />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceCards({ rows, currency }: { rows: PriceRow[]; currency: DisplayCurrency }) {
|
||||
return (
|
||||
<div className="price-cards">
|
||||
{rows.map((row, index) => (
|
||||
<article className="price-card" key={row.countryCode}>
|
||||
<div className="price-card-head">
|
||||
<span className="mobile-rank">#{index + 1}</span>
|
||||
<StatusBadge status={row.status} />
|
||||
</div>
|
||||
<div className="price-card-country">
|
||||
<span className="flag">{flagEmoji(row.countryCode)}</span>
|
||||
<div><strong>{row.countryName}</strong><span>{row.countryCode} · {row.currencyCode}</span></div>
|
||||
</div>
|
||||
<div className="mobile-price"><ConvertedPrice row={row} currency={currency} /><small>/ 用户 / 月</small></div>
|
||||
<div className="mobile-details">
|
||||
<span>官方原价 <strong>{formatLocal(row)}</strong></span>
|
||||
<span className={`tax-badge ${row.taxTreatment}`}>{taxLabel(row)}</span>
|
||||
</div>
|
||||
<a href={row.sourceUrl} target="_blank" rel="noreferrer">查看官方价格源 <ArrowUpRight size={15} /></a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRows() {
|
||||
return (
|
||||
<div className="loading-rows" aria-label="正在加载价格">
|
||||
{Array.from({ length: 5 }, (_, index) => <span key={index} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
DisplayCurrency,
|
||||
PriceRow,
|
||||
SortMode,
|
||||
StatusFilter,
|
||||
TaxFilter,
|
||||
} from "./types";
|
||||
|
||||
export function formatConverted(amount: number, currency: DisplayCurrency): string {
|
||||
const value = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
return `${currency === "CNY" ? "¥" : "$"}${value}`;
|
||||
}
|
||||
|
||||
export function formatLocal(row: PriceRow): string {
|
||||
const value = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: row.minorUnitExponent,
|
||||
maximumFractionDigits: row.minorUnitExponent,
|
||||
}).format(row.localAmount);
|
||||
return `${row.symbol}${value}`;
|
||||
}
|
||||
|
||||
export function flagEmoji(countryCode: string): string {
|
||||
return [...countryCode.toUpperCase()]
|
||||
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function relativeTime(iso: string): string {
|
||||
const delta = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(delta) || delta < 0) return "刚刚";
|
||||
const minutes = Math.floor(delta / 60_000);
|
||||
if (minutes < 1) return "刚刚";
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
export function taxLabel(row: PriceRow): string {
|
||||
const treatment = row.taxTreatment === "inclusive" ? "含税" : "未含税";
|
||||
const detail = [row.taxType?.toUpperCase(), row.taxPercent == null ? null : `${row.taxPercent}%`]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return detail ? `${treatment} · ${detail}` : treatment;
|
||||
}
|
||||
|
||||
export function filterAndSortRows(
|
||||
rows: PriceRow[],
|
||||
options: {
|
||||
query: string;
|
||||
tax: TaxFilter;
|
||||
status: StatusFilter;
|
||||
sort: SortMode;
|
||||
currency: DisplayCurrency;
|
||||
},
|
||||
): PriceRow[] {
|
||||
const query = options.query.trim().toLocaleLowerCase("zh-CN");
|
||||
const filtered = rows.filter((row) => {
|
||||
if (options.tax !== "all" && row.taxTreatment !== options.tax) return false;
|
||||
if (options.status !== "all" && row.status !== options.status) return false;
|
||||
if (!query) return true;
|
||||
return [row.countryName, row.countryCode, row.currencyCode]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("zh-CN")
|
||||
.includes(query);
|
||||
});
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (options.sort === "country") return a.countryName.localeCompare(b.countryName, "zh-CN");
|
||||
if (options.sort === "local") return a.localAmount - b.localAmount;
|
||||
if (options.sort === "updated") return b.fetchedAt.localeCompare(a.fetchedAt);
|
||||
const aValue = options.currency === "CNY" ? a.cnyAmount : a.usdAmount;
|
||||
const bValue = options.currency === "CNY" ? b.cnyAmount : b.usdAmount;
|
||||
return aValue - bValue;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
:root {
|
||||
font-family: Inter, "SF Pro Display", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #17201d;
|
||||
background: #f5f7f3;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--ink: #17201d;
|
||||
--muted: #65706c;
|
||||
--line: #dfe5df;
|
||||
--surface: #ffffff;
|
||||
--soft: #eef3ed;
|
||||
--green: #176b4d;
|
||||
--green-dark: #0f4e39;
|
||||
--lime: #d8f28d;
|
||||
--amber: #9a6415;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: #f5f7f3; }
|
||||
button, input, select { font: inherit; }
|
||||
a { color: inherit; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid rgba(23, 107, 77, .28); outline-offset: 2px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.app-shell { min-height: 100vh; overflow: hidden; }
|
||||
.topbar { width: min(1440px, calc(100% - 64px)); height: 88px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
|
||||
.brand { display: inline-flex; align-items: center; gap: 12px; text-decoration: none; }
|
||||
.brand > span:last-child { display: flex; flex-direction: column; gap: 2px; }
|
||||
.brand strong { font-size: 15px; letter-spacing: -.01em; }
|
||||
.brand small { color: var(--muted); font-size: 10px; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 50%; background: var(--green); display: grid; place-items: center; box-shadow: inset 0 0 0 7px rgba(255,255,255,.09); }
|
||||
.brand-mark::before { content: ""; width: 18px; height: 18px; border: 1px solid rgba(255,255,255,.75); border-radius: 50%; }
|
||||
.brand-mark span { position: absolute; width: 5px; height: 5px; background: var(--lime); border-radius: 50%; transform: translate(5px, -5px); }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 18px; }
|
||||
.source-pill { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: #2ca675; box-shadow: 0 0 0 4px rgba(44,166,117,.12); }
|
||||
.currency-toggle { display: inline-flex; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: rgba(255,255,255,.75); }
|
||||
.currency-toggle button { border: 0; background: transparent; color: var(--muted); padding: 8px 11px; border-radius: 8px; font-size: 12px; font-weight: 750; cursor: pointer; }
|
||||
.currency-toggle button.active { background: var(--ink); color: white; box-shadow: 0 3px 10px rgba(20,30,27,.14); }
|
||||
main { width: min(1440px, calc(100% - 64px)); margin: 0 auto; }
|
||||
.hero { min-height: 570px; display: grid; grid-template-columns: 1.2fr .8fr; gap: 80px; align-items: center; position: relative; }
|
||||
.hero::before { content: ""; position: absolute; width: 540px; height: 540px; right: -210px; top: -110px; border: 1px solid rgba(23,107,77,.08); border-radius: 50%; box-shadow: 0 0 0 90px rgba(23,107,77,.025), 0 0 0 180px rgba(23,107,77,.018); pointer-events: none; }
|
||||
.hero-copy { max-width: 780px; position: relative; z-index: 1; }
|
||||
.eyebrow, .section-kicker { color: var(--green); font-size: 11px; line-height: 1; font-weight: 800; letter-spacing: .18em; }
|
||||
.eyebrow { display: inline-flex; align-items: center; gap: 7px; border: 1px solid #cbdacf; background: rgba(255,255,255,.58); padding: 9px 12px; border-radius: 999px; }
|
||||
.hero h1 { margin: 26px 0 20px; font-family: Georgia, "Noto Serif SC", serif; font-size: clamp(52px, 6vw, 88px); line-height: .98; letter-spacing: -.055em; font-weight: 500; }
|
||||
.hero h1 em { color: var(--green); font-style: normal; }
|
||||
.hero-copy > p { color: var(--muted); max-width: 650px; margin: 0; font-size: 16px; line-height: 1.9; }
|
||||
.hero-notes { display: flex; flex-wrap: wrap; gap: 22px; margin-top: 30px; }
|
||||
.hero-notes span { display: inline-flex; align-items: center; gap: 7px; color: #4f5c57; font-size: 12px; font-weight: 650; }
|
||||
.hero-card { position: relative; z-index: 1; background: var(--ink); color: white; border-radius: 28px; padding: 30px; min-height: 390px; box-shadow: 0 28px 70px rgba(18,36,29,.18); overflow: hidden; }
|
||||
.hero-card::after { content: ""; position: absolute; width: 240px; height: 240px; right: -85px; bottom: -110px; border: 1px solid rgba(216,242,141,.18); border-radius: 50%; box-shadow: 0 0 0 45px rgba(216,242,141,.035), 0 0 0 90px rgba(216,242,141,.02); }
|
||||
.hero-card-top { display: flex; justify-content: space-between; align-items: center; color: #acb8b3; font-size: 12px; }
|
||||
.rank-badge { color: var(--ink); background: var(--lime); padding: 6px 9px; border-radius: 999px; font-weight: 800; }
|
||||
.lowest-country { display: flex; align-items: center; gap: 14px; margin-top: 50px; }
|
||||
.large-flag { font-size: 38px; filter: drop-shadow(0 5px 10px rgba(0,0,0,.18)); }
|
||||
.lowest-country div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.lowest-country strong { font-size: 20px; }
|
||||
.lowest-country span:last-child { color: #94a29d; font-size: 12px; }
|
||||
.lowest-price { margin-top: 28px; color: var(--lime); font-family: Georgia, serif; font-size: clamp(42px, 5vw, 66px); letter-spacing: -.04em; line-height: 1; }
|
||||
.lowest-price small { display: block; margin-top: 12px; font: 500 11px/1 Inter, sans-serif; letter-spacing: .1em; color: #899892; }
|
||||
.local-price-line { width: fit-content; margin-top: 24px; padding: 9px 12px; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; color: #93a09b; font-size: 12px; }
|
||||
.local-price-line strong { color: white; margin-left: 5px; }
|
||||
.hero-card-foot { position: absolute; left: 30px; bottom: 24px; display: flex; align-items: center; gap: 7px; color: #8e9d97; font-size: 10px; z-index: 1; }
|
||||
.hero-skeleton { height: 150px; margin-top: 58px; border-radius: 18px; background: linear-gradient(90deg, rgba(255,255,255,.04), rgba(255,255,255,.1), rgba(255,255,255,.04)); background-size: 200% 100%; animation: shimmer 1.4s infinite; }
|
||||
.sample-banner { display: flex; align-items: center; gap: 9px; margin: -4px 0 24px; padding: 13px 16px; border-radius: 12px; background: #fff7e7; border: 1px solid #eddcb9; color: #805a19; font-size: 12px; }
|
||||
.error-state { display: flex; align-items: center; gap: 16px; padding: 22px; border: 1px solid #e6c9c6; background: #fff3f2; border-radius: 16px; color: #8c3b34; }
|
||||
.error-state div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.error-state span { font-size: 12px; opacity: .8; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,255,.72); overflow: hidden; }
|
||||
.metric-card { min-height: 150px; padding: 24px; border-right: 1px solid var(--line); display: grid; grid-template-columns: 36px 1fr; grid-template-rows: auto auto auto; align-content: center; column-gap: 12px; }
|
||||
.metric-card:last-child { border-right: 0; }
|
||||
.metric-icon { grid-row: 1 / 4; width: 36px; height: 36px; display: grid; place-items: center; color: var(--green); background: var(--soft); border-radius: 10px; align-self: start; }
|
||||
.metric-card > span { color: var(--muted); font-size: 11px; }
|
||||
.metric-card > strong { margin: 5px 0 3px; font-family: Georgia, serif; font-size: 24px; font-weight: 600; }
|
||||
.metric-card > small { color: #8b9591; font-size: 10px; }
|
||||
.explorer { margin-top: 90px; }
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 24px; }
|
||||
.section-heading h2 { margin: 8px 0 0; font-family: Georgia, "Noto Serif SC", serif; font-size: 38px; font-weight: 500; letter-spacing: -.035em; }
|
||||
.section-heading p { color: var(--muted); font-size: 12px; }
|
||||
.section-heading p strong { color: var(--ink); }
|
||||
.controls { display: grid; grid-template-columns: minmax(260px, 1fr) auto auto auto; gap: 10px; margin-bottom: 16px; }
|
||||
.search-control, .select-control { min-height: 48px; display: flex; align-items: center; gap: 10px; padding: 0 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--muted); }
|
||||
.search-control input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 13px; }
|
||||
.search-control input::placeholder { color: #9ca6a2; }
|
||||
.select-control > span { font-size: 10px; font-weight: 800; white-space: nowrap; color: #87928e; }
|
||||
.select-control select { border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 12px; font-weight: 650; cursor: pointer; }
|
||||
.sort-control { background: var(--ink); border-color: var(--ink); color: white; }
|
||||
.sort-control select { color: white; }
|
||||
.sort-control option { color: var(--ink); }
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); box-shadow: 0 14px 45px rgba(25,48,39,.04); }
|
||||
table { width: 100%; min-width: 1060px; border-collapse: collapse; text-align: left; }
|
||||
th { padding: 15px 20px; color: #7a8581; background: #f0f3ef; border-bottom: 1px solid var(--line); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
td { padding: 18px 20px; border-bottom: 1px solid #edf0ed; font-size: 12px; vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr { transition: background .18s ease; }
|
||||
tbody tr:hover { background: #f9fbf8; }
|
||||
.country-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.rank { width: 24px; color: #a4ada9; font: 600 10px/1 monospace; }
|
||||
.flag { font-size: 25px; line-height: 1; }
|
||||
.country-cell div, .price-card-country div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.country-cell strong, .price-card-country strong { font-size: 13px; }
|
||||
.country-cell div span, .price-card-country div span { color: #8a9490; font-size: 10px; }
|
||||
.local-amount { display: block; font-size: 14px; }
|
||||
.per-user { display: block; margin-top: 4px; color: #929c98; font-size: 9px; }
|
||||
.converted-amount { color: var(--green); font: 600 20px/1 Georgia, serif; }
|
||||
.tax-badge, .status-badge { display: inline-flex; align-items: center; gap: 6px; width: fit-content; padding: 6px 8px; border-radius: 7px; font-size: 9px; font-weight: 750; white-space: nowrap; }
|
||||
.tax-badge.inclusive { color: #286c53; background: #e8f3ed; }
|
||||
.tax-badge.exclusive { color: #85601f; background: #fff4dd; }
|
||||
.status-badge.fresh { color: #356c57; background: #eef6f1; }
|
||||
.status-badge.stale { color: #98631d; background: #fff5e4; }
|
||||
.status-badge > span { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.updated-time { display: block; margin-top: 5px; color: #9aa39f; font-size: 9px; }
|
||||
.source-link { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 9px; color: var(--green); transition: .18s ease; }
|
||||
.source-link:hover { background: var(--green); border-color: var(--green); color: white; transform: translateY(-1px); }
|
||||
.price-cards { display: none; }
|
||||
.loading-rows { border: 1px solid var(--line); border-radius: 18px; background: white; overflow: hidden; }
|
||||
.loading-rows span { display: block; height: 76px; border-bottom: 1px solid var(--line); background: linear-gradient(90deg, #fff, #f1f4f0, #fff); background-size: 200% 100%; animation: shimmer 1.4s infinite; }
|
||||
.loading-rows span:last-child { border-bottom: 0; }
|
||||
.empty-state { min-height: 220px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; border: 1px dashed #cdd5d0; border-radius: 18px; color: #8b9691; font-size: 12px; }
|
||||
.explainers { margin: 90px 0 32px; display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.explainers article { padding: 36px 34px 40px 0; border-right: 1px solid var(--line); }
|
||||
.explainers article + article { padding-left: 34px; }
|
||||
.explainers article:last-child { border-right: 0; }
|
||||
.explainer-number { color: var(--green); font: 700 10px/1 monospace; }
|
||||
.explainers h3 { margin: 18px 0 10px; font-family: Georgia, "Noto Serif SC", serif; font-size: 20px; font-weight: 600; }
|
||||
.explainers p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||
.disclaimer { display: flex; align-items: flex-start; gap: 14px; padding: 20px 22px; border-radius: 16px; background: #ebefea; color: #66716c; }
|
||||
.disclaimer > svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.disclaimer p { margin: 0; font-size: 11px; line-height: 1.8; }
|
||||
.disclaimer strong { color: var(--ink); }
|
||||
.disclaimer a { display: inline-flex; align-items: center; gap: 3px; margin-left: 5px; color: var(--green); font-weight: 700; text-decoration: none; }
|
||||
footer { width: min(1440px, calc(100% - 64px)); margin: 80px auto 0; padding: 32px 0 40px; border-top: 1px solid var(--line); display: grid; grid-template-columns: 1fr 1.5fr 1fr; align-items: center; gap: 30px; color: #88928e; font-size: 9px; }
|
||||
.footer-brand .brand-mark { width: 30px; height: 30px; }
|
||||
.footer-brand .brand-mark::before { width: 14px; height: 14px; }
|
||||
footer p { text-align: center; line-height: 1.7; }
|
||||
footer > span:last-child { text-align: right; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.hero { grid-template-columns: 1fr .8fr; gap: 36px; }
|
||||
.metrics { grid-template-columns: repeat(2, 1fr); }
|
||||
.metric-card:nth-child(2) { border-right: 0; }
|
||||
.metric-card:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
|
||||
.controls { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.search-control { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar, main, footer { width: min(100% - 32px, 680px); }
|
||||
.topbar { height: 74px; }
|
||||
.source-pill { display: none; }
|
||||
.brand small { display: none; }
|
||||
.currency-toggle button { padding: 7px 9px; font-size: 10px; }
|
||||
.hero { min-height: auto; grid-template-columns: 1fr; gap: 34px; padding: 64px 0 46px; }
|
||||
.hero::before { width: 360px; height: 360px; right: -250px; }
|
||||
.hero h1 { margin-top: 22px; font-size: clamp(45px, 14vw, 68px); }
|
||||
.hero-copy > p { font-size: 14px; line-height: 1.8; }
|
||||
.hero-notes { gap: 12px 18px; margin-top: 24px; }
|
||||
.hero-card { min-height: 365px; padding: 26px; }
|
||||
.hero-card-foot { left: 26px; right: 20px; }
|
||||
.metrics { grid-template-columns: 1fr 1fr; }
|
||||
.metric-card { min-height: 132px; padding: 18px; grid-template-columns: 30px 1fr; column-gap: 9px; }
|
||||
.metric-icon { width: 30px; height: 30px; }
|
||||
.metric-card > strong { font-size: 19px; }
|
||||
.explorer { margin-top: 64px; }
|
||||
.section-heading { align-items: flex-end; }
|
||||
.section-heading h2 { font-size: 30px; }
|
||||
.controls { grid-template-columns: 1fr 1fr; }
|
||||
.search-control { grid-column: 1 / -1; }
|
||||
.sort-control { grid-column: 1 / -1; justify-content: center; }
|
||||
.table-wrap { display: none; }
|
||||
.price-cards { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||||
.price-card { padding: 20px; border: 1px solid var(--line); border-radius: 17px; background: white; box-shadow: 0 10px 28px rgba(28,50,42,.04); }
|
||||
.price-card-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.mobile-rank { color: var(--green); font: 700 10px/1 monospace; }
|
||||
.price-card-country { display: flex; align-items: center; gap: 12px; margin-top: 19px; }
|
||||
.price-card-country .flag { font-size: 30px; }
|
||||
.mobile-price { margin-top: 20px; color: var(--green); font: 600 34px/1 Georgia, serif; }
|
||||
.mobile-price small { display: block; margin-top: 7px; color: #95a09b; font: 500 9px/1 Inter, sans-serif; }
|
||||
.mobile-details { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; padding-top: 14px; border-top: 1px solid #edf0ed; color: var(--muted); font-size: 10px; }
|
||||
.mobile-details strong { color: var(--ink); margin-left: 4px; }
|
||||
.price-card > a { display: inline-flex; align-items: center; gap: 4px; margin-top: 18px; color: var(--green); font-size: 10px; font-weight: 750; text-decoration: none; }
|
||||
.explainers { grid-template-columns: 1fr; margin-top: 64px; }
|
||||
.explainers article, .explainers article + article { padding: 28px 0; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.explainers article:last-child { border-bottom: 0; }
|
||||
footer { margin-top: 56px; grid-template-columns: 1fr; text-align: center; }
|
||||
.footer-brand { justify-self: center; }
|
||||
footer p, footer > span:last-child { text-align: center; margin: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.topbar, main, footer { width: calc(100% - 24px); }
|
||||
.brand strong { font-size: 12px; }
|
||||
.brand-mark { width: 32px; height: 32px; }
|
||||
.hero { padding-top: 48px; }
|
||||
.hero-notes span:last-child { display: none; }
|
||||
.metrics { grid-template-columns: 1fr; }
|
||||
.metric-card { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.metric-card:last-child { border-bottom: 0; }
|
||||
.controls { grid-template-columns: 1fr; }
|
||||
.search-control, .sort-control { grid-column: auto; }
|
||||
.section-heading p { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export type DisplayCurrency = "CNY" | "USD";
|
||||
export type TaxTreatment = "inclusive" | "exclusive";
|
||||
export type RowStatus = "fresh" | "stale";
|
||||
|
||||
export type PriceRow = {
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
currencyCode: string;
|
||||
symbol: string;
|
||||
minorUnitExponent: number;
|
||||
localAmount: number;
|
||||
usdAmount: number;
|
||||
cnyAmount: number;
|
||||
taxTreatment: TaxTreatment;
|
||||
taxType: string | null;
|
||||
taxPercent: number | null;
|
||||
status: RowStatus;
|
||||
fetchedAt: string;
|
||||
sourceUrl: string;
|
||||
};
|
||||
|
||||
export type PricingSnapshot = {
|
||||
version: 1;
|
||||
mode: "live" | "sample";
|
||||
generatedAt: string;
|
||||
pricingSource: {
|
||||
name: string;
|
||||
urlTemplate: string;
|
||||
};
|
||||
fx: {
|
||||
baseCurrency: "USD";
|
||||
date: string;
|
||||
source: string;
|
||||
sourceUrls: string[];
|
||||
rates: Record<string, number>;
|
||||
};
|
||||
coverage: {
|
||||
requested: number;
|
||||
success: number;
|
||||
unsupported: number;
|
||||
failed: number;
|
||||
stale: number;
|
||||
};
|
||||
rows: PriceRow[];
|
||||
};
|
||||
|
||||
export type SortMode = "converted" | "country" | "local" | "updated";
|
||||
export type TaxFilter = "all" | TaxTreatment;
|
||||
export type StatusFilter = "all" | RowStatus;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,135 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ISO_COUNTRY_CODES,
|
||||
collectSnapshot,
|
||||
convertAmount,
|
||||
fetchJsonWithRetry,
|
||||
mergeStaleRows,
|
||||
parsePriceResponse,
|
||||
validateCoverage,
|
||||
withConvertedAmounts,
|
||||
} from "../scripts/collect-prices.mjs";
|
||||
|
||||
const fixtures = path.resolve(process.cwd(), "tests/fixtures");
|
||||
|
||||
async function fixture(name) {
|
||||
return JSON.parse(await readFile(path.join(fixtures, name), "utf8"));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("official Business monthly parser", () => {
|
||||
it("contains the full ISO alpha-2 probe list", () => {
|
||||
expect(ISO_COUNTRY_CODES).toHaveLength(249);
|
||||
expect(new Set(ISO_COUNTRY_CODES).size).toBe(249);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["us.json", "US", 25, "USD", 2, "exclusive"],
|
||||
["sg.json", "SG", 32, "SGD", 2, "exclusive"],
|
||||
["in.json", "IN", 2250, "INR", 2, "exclusive"],
|
||||
["jp.json", "JP", 3850, "JPY", 0, "exclusive"],
|
||||
["de.json", "DE", 26, "EUR", 2, "inclusive"],
|
||||
["br.json", "BR", 130, "BRL", 2, "exclusive"],
|
||||
])("parses %s", async (name, code, amount, currency, exponent, tax) => {
|
||||
const row = parsePriceResponse(await fixture(name), code, "2026-07-22T00:00:00.000Z");
|
||||
expect(row).toMatchObject({
|
||||
countryCode: code,
|
||||
localAmount: amount,
|
||||
currencyCode: currency,
|
||||
minorUnitExponent: exponent,
|
||||
taxTreatment: tax,
|
||||
status: "fresh",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects mismatched country responses", async () => {
|
||||
const raw = await fixture("us.json");
|
||||
expect(() => parsePriceResponse(raw, "SG")).toThrow(/Country mismatch/);
|
||||
});
|
||||
|
||||
it("classifies a 404 response as unsupported", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
||||
await expect(fetchJsonWithRetry("https://example.com/ZZ", { retries: 1 })).resolves.toEqual({
|
||||
kind: "unsupported",
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversion and snapshot guards", () => {
|
||||
const rates = { USD: 1, CNY: 6.8, SGD: 1.25 };
|
||||
|
||||
it("converts local currency through USD", () => {
|
||||
expect(convertAmount(25, "USD", rates)).toEqual({ usdAmount: 25, cnyAmount: 170 });
|
||||
expect(convertAmount(32, "SGD", rates)).toEqual({ usdAmount: 25.6, cnyAmount: 174.08 });
|
||||
});
|
||||
|
||||
it("rejects missing rates", () => {
|
||||
expect(() => convertAmount(100, "JPY", rates)).toThrow(/Missing FX rate/);
|
||||
});
|
||||
|
||||
it("carries a failed row for at most fourteen days and refreshes its conversion", () => {
|
||||
const previous = {
|
||||
countryCode: "SG",
|
||||
countryName: "新加坡",
|
||||
currencyCode: "SGD",
|
||||
symbol: "S$",
|
||||
minorUnitExponent: 2,
|
||||
localAmount: 32,
|
||||
usdAmount: 99,
|
||||
cnyAmount: 99,
|
||||
taxTreatment: "exclusive",
|
||||
taxType: "gst",
|
||||
taxPercent: 9,
|
||||
status: "fresh",
|
||||
fetchedAt: "2026-07-10T00:00:00.000Z",
|
||||
sourceUrl: "https://chatgpt.com/backend-anon/checkout_pricing_config/configs/SG",
|
||||
};
|
||||
const rows = mergeStaleRows([], ["SG"], [previous], rates, new Date("2026-07-22T00:00:00.000Z"));
|
||||
expect(rows[0]).toMatchObject({ status: "stale", usdAmount: 25.6, cnyAmount: 174.08 });
|
||||
expect(mergeStaleRows([], ["SG"], [previous], rates, new Date("2026-07-26T00:00:00.000Z"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("enforces baseline and 90 percent coverage", () => {
|
||||
const us = withConvertedAmounts({
|
||||
countryCode: "US", countryName: "美国", currencyCode: "USD", symbol: "$", minorUnitExponent: 2,
|
||||
localAmount: 25, taxTreatment: "exclusive", taxType: "other", taxPercent: null,
|
||||
status: "fresh", fetchedAt: "2026-07-22T00:00:00.000Z", sourceUrl: "https://example.com/US",
|
||||
}, rates);
|
||||
const sg = withConvertedAmounts({ ...us, countryCode: "SG", countryName: "新加坡", currencyCode: "SGD", symbol: "S$", localAmount: 32 }, rates);
|
||||
const snapshot = { fx: { rates }, rows: [us, sg] };
|
||||
expect(() => validateCoverage(snapshot, null, 2)).not.toThrow();
|
||||
expect(() => validateCoverage(snapshot, { rows: Array.from({ length: 3 }) }, 2)).toThrow(/90%/);
|
||||
});
|
||||
|
||||
it("builds a validated snapshot from pricing and FX responses", async () => {
|
||||
const usFixture = await fixture("us.json");
|
||||
const sgFixture = await fixture("sg.json");
|
||||
vi.stubGlobal("fetch", vi.fn(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/US")) return { ok: true, status: 200, json: async () => usFixture };
|
||||
if (url.endsWith("/SG")) return { ok: true, status: 200, json: async () => sgFixture };
|
||||
if (url.endsWith("/ZZ")) return { ok: false, status: 404 };
|
||||
if (url.startsWith("https://api.frankfurter.dev/")) {
|
||||
return { ok: true, status: 200, json: async () => ({ base: "USD", date: "2026-07-22", rates: { CNY: 6.8, SGD: 1.25 } }) };
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}));
|
||||
|
||||
const snapshot = await collectSnapshot({
|
||||
countryCodes: ["US", "SG", "ZZ"],
|
||||
generatedAt: "2026-07-22T00:00:00.000Z",
|
||||
concurrency: 2,
|
||||
});
|
||||
|
||||
expect(snapshot.coverage).toEqual({ requested: 3, success: 2, unsupported: 1, failed: 0, stale: 0 });
|
||||
expect(snapshot.rows.map((row) => row.countryCode)).toEqual(["US", "SG"]);
|
||||
expect(snapshot.rows.find((row) => row.countryCode === "SG")).toMatchObject({ usdAmount: 25.6, cnyAmount: 174.08 });
|
||||
expect(() => validateCoverage(snapshot, null, 2)).not.toThrow();
|
||||
});
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"country_code":"BR","currency_config":{"business":{"month":{"amount":130,"tax":"exclusive"}}},"tax_type":"vat","tax_percent":0,"symbol_code":"BRL","symbol":"R$","minor_unit_exponent":2}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"country_code":"DE","currency_config":{"business":{"month":{"amount":26,"tax":"inclusive"}}},"tax_type":"vat","tax_percent":19,"symbol_code":"EUR","symbol":"€","minor_unit_exponent":2}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"country_code":"IN","currency_config":{"business":{"month":{"amount":2250,"tax":"exclusive"}}},"tax_type":"gst","tax_percent":18,"symbol_code":"INR","symbol":"₹","minor_unit_exponent":2}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"country_code":"JP","currency_config":{"business":{"month":{"amount":3850,"tax":"exclusive"}}},"tax_type":"jct","tax_percent":10,"symbol_code":"JPY","symbol":"¥","minor_unit_exponent":0}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"country_code":"SG","currency_config":{"business":{"month":{"amount":32,"tax":"exclusive"}}},"tax_type":"gst","tax_percent":9,"symbol_code":"SGD","symbol":"S$","minor_unit_exponent":2}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"detail":"Not found"}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"country_code":"US","currency_config":{"business":{"month":{"amount":25,"tax":"exclusive"}}},"tax_type":"other","symbol_code":"USD","symbol":"$","minor_unit_exponent":2}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
build: {
|
||||
target: "es2022",
|
||||
sourcemap: true,
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test-setup.ts",
|
||||
css: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user