diff --git a/README.md b/README.md index b0102c0..1dab6f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Business Price Radar +# Business Toolkit -一个独立的 ChatGPT Business 全球月付地区价格查询工具。项目从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格,并按公开汇率换算为人民币或美元。 +一个独立的 ChatGPT Business 价格与结账工具箱。价格雷达从 `chatgpt.com` 的公开结账价格配置中读取 `currency_config.business.month`,保留官方原币价格并按公开汇率换算;脚本生成器根据优惠码、国家和货币在浏览器本地生成可复制的结账脚本。 > 本项目不是 OpenAI 官方产品,与 OpenAI 没有隶属、合作或背书关系。实际价格、税费、付款资格和地区可用性以结账页为准。 @@ -10,6 +10,9 @@ - 仅展示标准 ChatGPT Business 月付价格,不包含年付或非营利套餐。 - 显示官方原币价格、人民币/美元估算、含税或未含税口径。 - 支持搜索、筛选、排序以及桌面表格和移动端卡片。 +- 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。 +- 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。 +- 优惠码仅在当前浏览器内处理,不写入网址或本地存储。 - GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。 - 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。 @@ -52,6 +55,12 @@ npm run build 采集请求不使用 Cookie、设备 ID、会话 ID、登录凭据或浏览器指纹头。 +## 脚本生成器 + +顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX¤cy=XXX` 预填国家和货币,优惠码不会出现在网址中。 + +生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。它只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。 + ### 发布保护 - 首次快照必须包含新鲜的 US、至少一个非 USD 地区、CNY 汇率和至少 20 个有效地区。 diff --git a/index.html b/index.html index 31a022b..9d1c64f 100644 --- a/index.html +++ b/index.html @@ -6,15 +6,23 @@ - + - Business Price Radar|全球月付价格 + + + + + + + + + Business Toolkit|价格与结账工具
diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..b6543ea Binary files /dev/null and b/public/og.png differ diff --git a/src/App.test.tsx b/src/App.test.tsx index 57ad49c..ccdb197 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -6,6 +6,7 @@ import App from "./App"; describe("pricing explorer", () => { beforeEach(() => { + window.history.replaceState({}, "", "/"); window.localStorage.clear(); vi.stubGlobal("fetch", vi.fn() .mockResolvedValueOnce({ ok: false, status: 404 }) @@ -52,4 +53,85 @@ describe("pricing explorer", () => { expect(screen.getAllByText("德国").length).toBeGreaterThan(0); expect(screen.queryByText("新加坡")).not.toBeInTheDocument(); }); + + it("opens the generator with US and EGP defaults and responds to URL navigation", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /脚本生成器/ })); + expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("US"); + expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("EGP"); + expect(new URLSearchParams(window.location.search).get("view")).toBe("generator"); + + window.history.pushState({}, "", "/?view=generator&country=JP¤cy=JPY"); + window.dispatchEvent(new PopStateEvent("popstate")); + await waitFor(() => expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("JP")); + expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("JPY"); + }); + + it("prefills a region from the radar and preserves the price filters on return", async () => { + const user = userEvent.setup(); + render(); + await screen.findAllByText("新加坡"); + + const search = screen.getByPlaceholderText("搜索国家、代码或币种"); + await user.type(search, "新加坡"); + await user.click(screen.getAllByRole("button", { name: "为新加坡生成脚本" })[0]); + + expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("SG"); + expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("SGD"); + expect(new URLSearchParams(window.location.search).get("country")).toBe("SG"); + expect(new URLSearchParams(window.location.search).get("currency")).toBe("SGD"); + + await user.click(screen.getByRole("button", { name: /返回价格雷达/ })); + expect(screen.getByPlaceholderText("搜索国家、代码或币种")).toHaveValue("新加坡"); + }); + + it("blocks an empty coupon, then copies valid generated code without storing the coupon", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + render(); + await user.click(screen.getByRole("button", { name: /脚本生成器/ })); + + const copyButton = screen.getByRole("button", { name: /复制代码/ }); + expect(copyButton).toHaveAttribute("aria-disabled", "true"); + await user.click(copyButton); + const coupon = screen.getByPlaceholderText("例如:XXXXXXXXXXXX"); + expect(coupon).toHaveFocus(); + expect(screen.getByText("请输入优惠码")).toBeInTheDocument(); + + await user.type(coupon, "SAVE20"); + expect(copyButton).toHaveAttribute("aria-disabled", "false"); + await user.click(screen.getByRole("button", { name: /复制代码/ })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('const COUPON = "SAVE20"')); + expect(window.location.search).not.toContain("SAVE20"); + expect(window.localStorage.getItem("coupon")).toBeNull(); + }); + + it("downloads valid code and resets to US and EGP", async () => { + const user = userEvent.setup(); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + const createObjectURL = vi.fn(() => "blob:checkout-script"); + const revokeObjectURL = vi.fn(); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectURL }); + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectURL }); + render(); + await user.click(screen.getByRole("button", { name: /脚本生成器/ })); + + await user.type(screen.getByPlaceholderText("例如:XXXXXXXXXXXX"), "SAVE20"); + await user.clear(screen.getByLabelText(/国家 ISO 缩写/)); + await user.type(screen.getByLabelText(/国家 ISO 缩写/), "sg"); + await user.click(screen.getByRole("button", { name: /下载/ })); + expect(createObjectURL).toHaveBeenCalled(); + expect(click).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:checkout-script"); + + await user.click(screen.getByRole("button", { name: /恢复默认值/ })); + expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("US"); + expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("EGP"); + }); }); diff --git a/src/App.tsx b/src/App.tsx index e96d9b5..3e69b89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { CalendarClock, CheckCircle2, CircleDollarSign, + Code2, Database, Github, Info, @@ -12,6 +13,12 @@ import { Users, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import CheckoutGenerator from "./CheckoutGenerator"; +import { + DEFAULT_CHECKOUT_COUNTRY, + DEFAULT_CHECKOUT_CURRENCY, + isSupportedCheckoutCurrency, +} from "./checkout-generator"; import { filterAndSortRows, flagEmoji, formatConverted, formatLocal, relativeTime, taxLabel } from "./lib"; import type { DisplayCurrency, @@ -24,6 +31,18 @@ import type { const currencyStorageKey = "business-price-radar:currency"; +type AppView = "prices" | "generator"; +type AppRoute = { view: AppView; country: string; currency: string }; + +function readRoute(): AppRoute { + const params = new URLSearchParams(window.location.search); + return { + view: params.get("view") === "generator" ? "generator" : "prices", + country: params.get("country")?.toUpperCase() || DEFAULT_CHECKOUT_COUNTRY, + currency: params.get("currency")?.toUpperCase() || DEFAULT_CHECKOUT_CURRENCY, + }; +} + async function loadSnapshot(): Promise { const liveUrl = new URL("data/prices.json", document.baseURI); const sampleUrl = new URL("data/sample-prices.json", document.baseURI); @@ -51,6 +70,7 @@ function initialCurrency(): DisplayCurrency { } export default function App() { + const [route, setRoute] = useState(readRoute); const [snapshot, setSnapshot] = useState(null); const [loadingError, setLoadingError] = useState(null); const [currency, setCurrency] = useState(initialCurrency); @@ -73,6 +93,12 @@ export default function App() { }; }, []); + useEffect(() => { + const handlePopState = () => setRoute(readRoute()); + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, []); + useEffect(() => { window.localStorage.setItem(currencyStorageKey, currency); }, [currency]); @@ -103,22 +129,49 @@ export default function App() { window.localStorage.setItem(currencyStorageKey, value); }; + const navigate = (nextView: AppView, row?: Pick) => { + const url = new URL(window.location.href); + if (nextView === "generator") { + url.searchParams.set("view", "generator"); + if (row) { + url.searchParams.set("country", row.countryCode); + url.searchParams.set("currency", row.currencyCode); + } else { + url.searchParams.delete("country"); + url.searchParams.delete("currency"); + } + } else { + url.searchParams.delete("view"); + url.searchParams.delete("country"); + url.searchParams.delete("currency"); + } + window.history.pushState({}, "", url); + setRoute(readRoute()); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + return (
- + +
- - OpenAI 公开配置 - + {route.view === "prices" ? OpenAI 公开配置 : null} - + {route.view === "prices" ? : null}
+ {route.view === "prices" ? ( + <>
CHATGPT BUSINESS · MONTHLY @@ -266,8 +321,8 @@ export default function App() { {!snapshot && !loadingError ? : null} {snapshot && rows.length ? ( <> - - + navigate("generator", row)} /> + navigate("generator", row)} /> ) : null} {snapshot && !rows.length ? ( @@ -279,7 +334,7 @@ export default function App() {
01

价格从哪里来?

-

报价来自 chatgpt.com 的公开结账价格配置接口。本站只读取 Business 月付字段,不参与购买。

+

报价来自 chatgpt.com 的公开结账价格配置接口。价格雷达只读取 Business 月付字段。

02 @@ -296,17 +351,26 @@ export default function App() {

- 独立信息工具,非 OpenAI 官方产品。 - 实际价格、税费、付款资格与地区可用性以结账页为准。ChatGPT Business 按用户计费,标准席位至少购买 2 个。 + 独立工具,非 OpenAI 官方产品。 + 价格数据来自公开配置;脚本仅在浏览器本地生成,不会由本站执行。实际价格、税费、付款资格与地区可用性以结账页为准。 查看官方账单说明

+ + ) : ( + navigate("prices")} + /> + )}