This commit is contained in:
Kaihua
2026-07-23 09:31:05 +08:00
commit 91be9ba320
30 changed files with 4939 additions and 0 deletions
+23
View File
@@ -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
+98
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
public/data/prices.json
coverage/
*.local
.DS_Store
npm-debug.log*
*.tsbuildinfo
+21
View File
@@ -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.
+11
View File
@@ -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.
+91
View File
@@ -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
View File
@@ -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 RadarChatGPT 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>
+3143
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
+129
View File
@@ -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"
}
]
}
+365
View File
@@ -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;
});
}
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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;
});
}
+10
View File
@@ -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
View File
@@ -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; }
}
+2
View File
@@ -0,0 +1,2 @@
import "@testing-library/jest-dom/vitest";
+49
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+135
View File
@@ -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();
});
});
+1
View File
@@ -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}
+1
View File
@@ -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}
+1
View File
@@ -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}
+1
View File
@@ -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}
+1
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
{"detail":"Not found"}
+1
View File
@@ -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}
+21
View File
@@ -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"]
}
+16
View File
@@ -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,
},
});