This commit is contained in:
Kaihua
2026-07-30 21:54:41 +08:00
parent 2ec1502058
commit 04df2e0fed
10 changed files with 824 additions and 29 deletions
+11 -2
View File
@@ -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 没有隶属、合作或背书关系。实际价格、税费、付款资格和地区可用性以结账页为准。 > 本项目不是 OpenAI 官方产品,与 OpenAI 没有隶属、合作或背书关系。实际价格、税费、付款资格和地区可用性以结账页为准。
@@ -10,6 +10,9 @@
- 仅展示标准 ChatGPT Business 月付价格,不包含年付或非营利套餐。 - 仅展示标准 ChatGPT Business 月付价格,不包含年付或非营利套餐。
- 显示官方原币价格、人民币/美元估算、含税或未含税口径。 - 显示官方原币价格、人民币/美元估算、含税或未含税口径。
- 支持搜索、筛选、排序以及桌面表格和移动端卡片。 - 支持搜索、筛选、排序以及桌面表格和移动端卡片。
- 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。
- 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。
- 优惠码仅在当前浏览器内处理,不写入网址或本地存储。
- GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。 - GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。
- 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。 - 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。
@@ -52,6 +55,12 @@ npm run build
采集请求不使用 Cookie、设备 ID、会话 ID、登录凭据或浏览器指纹头。 采集请求不使用 Cookie、设备 ID、会话 ID、登录凭据或浏览器指纹头。
## 脚本生成器
顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX&currency=XXX` 预填国家和货币,优惠码不会出现在网址中。
生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。它只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。
### 发布保护 ### 发布保护
- 首次快照必须包含新鲜的 US、至少一个非 USD 地区、CNY 汇率和至少 20 个有效地区。 - 首次快照必须包含新鲜的 US、至少一个非 USD 地区、CNY 汇率和至少 20 个有效地区。
+12 -4
View File
@@ -6,15 +6,23 @@
<meta name="theme-color" content="#f5f7f3" /> <meta name="theme-color" content="#f5f7f3" />
<meta <meta
name="description" name="description"
content="查 ChatGPT Business 在不同国家和地区的官方月付价格,并换算为人民币或美元。" content="查 ChatGPT Business 全球月付价格,并按国家、货币和优惠码生成结账脚本。"
/> />
<meta property="og:title" content="Business Price RadarChatGPT Business 全球月付价格" /> <meta property="og:title" content="Business Toolkit|全球月付价格与脚本生成器" />
<meta <meta
property="og:description" property="og:description"
content="独立的 ChatGPT Business 地区月价查询汇率换算工具。" content="独立的 ChatGPT Business 地区月价查询汇率换算与本地脚本生成工具。"
/> />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<title>Business Price Radar|全球月付价格</title> <meta property="og:image" content="https://zhangkaihua88.github.io/chatgpt-business-price-radar/og.png" />
<meta property="og:image:width" content="1731" />
<meta property="og:image:height" content="909" />
<meta property="og:image:alt" content="Business Toolkit 全球月付价格与脚本生成器" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Business Toolkit|全球月付价格与脚本生成器" />
<meta name="twitter:description" content="查询全球月付价格,并在浏览器本地生成结账脚本。" />
<meta name="twitter:image" content="https://zhangkaihua88.github.io/chatgpt-business-price-radar/og.png" />
<title>Business Toolkit|价格与结账工具</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+82
View File
@@ -6,6 +6,7 @@ import App from "./App";
describe("pricing explorer", () => { describe("pricing explorer", () => {
beforeEach(() => { beforeEach(() => {
window.history.replaceState({}, "", "/");
window.localStorage.clear(); window.localStorage.clear();
vi.stubGlobal("fetch", vi.fn() vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce({ ok: false, status: 404 }) .mockResolvedValueOnce({ ok: false, status: 404 })
@@ -52,4 +53,85 @@ describe("pricing explorer", () => {
expect(screen.getAllByText("德国").length).toBeGreaterThan(0); expect(screen.getAllByText("德国").length).toBeGreaterThan(0);
expect(screen.queryByText("新加坡")).not.toBeInTheDocument(); 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(<App />);
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&currency=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(<App />);
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(<App />);
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(<App />);
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");
});
}); });
+118 -20
View File
@@ -4,6 +4,7 @@ import {
CalendarClock, CalendarClock,
CheckCircle2, CheckCircle2,
CircleDollarSign, CircleDollarSign,
Code2,
Database, Database,
Github, Github,
Info, Info,
@@ -12,6 +13,12 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "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 { filterAndSortRows, flagEmoji, formatConverted, formatLocal, relativeTime, taxLabel } from "./lib";
import type { import type {
DisplayCurrency, DisplayCurrency,
@@ -24,6 +31,18 @@ import type {
const currencyStorageKey = "business-price-radar:currency"; 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<PricingSnapshot> { async function loadSnapshot(): Promise<PricingSnapshot> {
const liveUrl = new URL("data/prices.json", document.baseURI); const liveUrl = new URL("data/prices.json", document.baseURI);
const sampleUrl = new URL("data/sample-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() { export default function App() {
const [route, setRoute] = useState<AppRoute>(readRoute);
const [snapshot, setSnapshot] = useState<PricingSnapshot | null>(null); const [snapshot, setSnapshot] = useState<PricingSnapshot | null>(null);
const [loadingError, setLoadingError] = useState<string | null>(null); const [loadingError, setLoadingError] = useState<string | null>(null);
const [currency, setCurrency] = useState<DisplayCurrency>(initialCurrency); const [currency, setCurrency] = useState<DisplayCurrency>(initialCurrency);
@@ -73,6 +93,12 @@ export default function App() {
}; };
}, []); }, []);
useEffect(() => {
const handlePopState = () => setRoute(readRoute());
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);
useEffect(() => { useEffect(() => {
window.localStorage.setItem(currencyStorageKey, currency); window.localStorage.setItem(currencyStorageKey, currency);
}, [currency]); }, [currency]);
@@ -103,22 +129,49 @@ export default function App() {
window.localStorage.setItem(currencyStorageKey, value); window.localStorage.setItem(currencyStorageKey, value);
}; };
const navigate = (nextView: AppView, row?: Pick<PriceRow, "countryCode" | "currencyCode">) => {
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 ( return (
<div className="app-shell"> <div className="app-shell">
<header className="topbar"> <header className="topbar">
<a className="brand" href="./" aria-label="Business Price Radar 首页"> <button className="brand brand-button" type="button" onClick={() => navigate("prices")} aria-label="Business Toolkit 首页">
<span className="brand-mark" aria-hidden="true"> <span className="brand-mark" aria-hidden="true">
<span /> <span />
</span> </span>
<span> <span>
<strong>Business Price Radar</strong> <strong>Business Toolkit</strong>
<small></small> <small></small>
</span> </span>
</a> </button>
<nav className="tool-navigation" aria-label="工具导航">
<button className={route.view === "prices" ? "active" : ""} type="button" onClick={() => navigate("prices")}>
<CircleDollarSign size={15} />
</button>
<button className={route.view === "generator" ? "active" : ""} type="button" onClick={() => navigate("generator")}>
<Code2 size={15} />
</button>
</nav>
<div className="topbar-actions"> <div className="topbar-actions">
<span className="source-pill"> {route.view === "prices" ? <span className="source-pill"><span className="live-dot" /> OpenAI </span> : null}
<span className="live-dot" /> OpenAI
</span>
<a <a
className="github-badge" className="github-badge"
href="https://github.com/zhangkaihua88/chatgpt-business-price-radar" href="https://github.com/zhangkaihua88/chatgpt-business-price-radar"
@@ -130,11 +183,13 @@ export default function App() {
<Github size={16} aria-hidden="true" /> <Github size={16} aria-hidden="true" />
<span>GitHub</span> <span>GitHub</span>
</a> </a>
<CurrencyToggle value={currency} onChange={setDisplayCurrency} /> {route.view === "prices" ? <CurrencyToggle value={currency} onChange={setDisplayCurrency} /> : null}
</div> </div>
</header> </header>
<main> <main>
{route.view === "prices" ? (
<>
<section className="hero"> <section className="hero">
<div className="hero-copy"> <div className="hero-copy">
<span className="eyebrow"><CircleDollarSign size={15} /> CHATGPT BUSINESS · MONTHLY</span> <span className="eyebrow"><CircleDollarSign size={15} /> CHATGPT BUSINESS · MONTHLY</span>
@@ -266,8 +321,8 @@ export default function App() {
{!snapshot && !loadingError ? <LoadingRows /> : null} {!snapshot && !loadingError ? <LoadingRows /> : null}
{snapshot && rows.length ? ( {snapshot && rows.length ? (
<> <>
<PriceTable rows={rows} currency={currency} /> <PriceTable rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row)} />
<PriceCards rows={rows} currency={currency} /> <PriceCards rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row)} />
</> </>
) : null} ) : null}
{snapshot && !rows.length ? ( {snapshot && !rows.length ? (
@@ -279,7 +334,7 @@ export default function App() {
<article> <article>
<span className="explainer-number">01</span> <span className="explainer-number">01</span>
<h3></h3> <h3></h3>
<p> chatgpt.com Business </p> <p> chatgpt.com Business </p>
</article> </article>
<article> <article>
<span className="explainer-number">02</span> <span className="explainer-number">02</span>
@@ -296,17 +351,26 @@ export default function App() {
<section className="disclaimer"> <section className="disclaimer">
<Info size={19} /> <Info size={19} />
<p> <p>
<strong> OpenAI </strong> <strong> OpenAI </strong>
ChatGPT Business 2
<a href="https://help.openai.com/en/articles/8792536" target="_blank" rel="noreferrer"> <ArrowUpRight size={14} /></a> <a href="https://help.openai.com/en/articles/8792536" target="_blank" rel="noreferrer"> <ArrowUpRight size={14} /></a>
</p> </p>
</section> </section>
</>
) : (
<CheckoutGenerator
initialCountry={route.country}
initialCurrency={route.currency}
countries={snapshot?.rows ?? []}
onBack={() => navigate("prices")}
/>
)}
</main> </main>
<footer> <footer>
<div className="brand footer-brand"> <div className="brand footer-brand">
<span className="brand-mark" aria-hidden="true"><span /></span> <span className="brand-mark" aria-hidden="true"><span /></span>
<span><strong>Business Price Radar</strong><small>Independent pricing reference</small></span> <span><strong>Business Toolkit</strong><small>Independent pricing & checkout tools</small></span>
</div> </div>
<p>ChatGPT OpenAI OpenAI </p> <p>ChatGPT OpenAI OpenAI </p>
<span>MIT License · Data refreshed by GitHub Actions</span> <span>MIT License · Data refreshed by GitHub Actions</span>
@@ -357,7 +421,15 @@ function StatusBadge({ status }: { status: PriceRow["status"] }) {
); );
} }
function PriceTable({ rows, currency }: { rows: PriceRow[]; currency: DisplayCurrency }) { function PriceTable({
rows,
currency,
onGenerate,
}: {
rows: PriceRow[];
currency: DisplayCurrency;
onGenerate: (row: PriceRow) => void;
}) {
return ( return (
<div className="table-wrap"> <div className="table-wrap">
<table> <table>
@@ -368,7 +440,7 @@ function PriceTable({ rows, currency }: { rows: PriceRow[]; currency: DisplayCur
<th scope="col"> {currency}</th> <th scope="col"> {currency}</th>
<th scope="col"></th> <th scope="col"></th>
<th scope="col"></th> <th scope="col"></th>
<th scope="col"><span className="sr-only"></span></th> <th scope="col"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -385,9 +457,19 @@ function PriceTable({ rows, currency }: { rows: PriceRow[]; currency: DisplayCur
<td><strong className="converted-amount"><ConvertedPrice row={row} currency={currency} /></strong></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><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><StatusBadge status={row.status} /><span className="updated-time">{relativeTime(row.fetchedAt)}</span></td>
<td> <td className="row-actions">
<button
className="generate-row-button"
type="button"
disabled={!isSupportedCheckoutCurrency(row.currencyCode)}
onClick={() => onGenerate(row)}
aria-label={`${row.countryName}生成脚本`}
title={isSupportedCheckoutCurrency(row.currencyCode) ? "带入国家和货币生成脚本" : "该币种暂不支持脚本生成"}
>
<Code2 size={14} />
</button>
<a className="source-link" href={row.sourceUrl} target="_blank" rel="noreferrer" aria-label={`查看${row.countryName}官方价格源`}> <a className="source-link" href={row.sourceUrl} target="_blank" rel="noreferrer" aria-label={`查看${row.countryName}官方价格源`}>
<ArrowUpRight size={17} /> <ArrowUpRight size={16} />
</a> </a>
</td> </td>
</tr> </tr>
@@ -398,7 +480,15 @@ function PriceTable({ rows, currency }: { rows: PriceRow[]; currency: DisplayCur
); );
} }
function PriceCards({ rows, currency }: { rows: PriceRow[]; currency: DisplayCurrency }) { function PriceCards({
rows,
currency,
onGenerate,
}: {
rows: PriceRow[];
currency: DisplayCurrency;
onGenerate: (row: PriceRow) => void;
}) {
return ( return (
<div className="price-cards"> <div className="price-cards">
{rows.map((row, index) => ( {rows.map((row, index) => (
@@ -416,7 +506,15 @@ function PriceCards({ rows, currency }: { rows: PriceRow[]; currency: DisplayCur
<span> <strong>{formatLocal(row)}</strong></span> <span> <strong>{formatLocal(row)}</strong></span>
<span className={`tax-badge ${row.taxTreatment}`}>{taxLabel(row)}</span> <span className={`tax-badge ${row.taxTreatment}`}>{taxLabel(row)}</span>
</div> </div>
<a href={row.sourceUrl} target="_blank" rel="noreferrer"> <ArrowUpRight size={15} /></a> <div className="mobile-card-actions">
<button
type="button"
disabled={!isSupportedCheckoutCurrency(row.currencyCode)}
onClick={() => onGenerate(row)}
aria-label={`${row.countryName}生成脚本`}
><Code2 size={14} /> </button>
<a href={row.sourceUrl} target="_blank" rel="noreferrer"> <ArrowUpRight size={15} /></a>
</div>
</article> </article>
))} ))}
</div> </div>
+287
View File
@@ -0,0 +1,287 @@
import {
ArrowLeft,
Check,
Code2,
Copy,
Download,
Info,
RotateCcw,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
CHECKOUT_CURRENCIES,
DEFAULT_CHECKOUT_COUNTRY,
DEFAULT_CHECKOUT_CURRENCY,
generateCheckoutScript,
isSupportedCheckoutCurrency,
normalizeIsoInput,
validateCheckoutInput,
type CheckoutScriptInput,
type CheckoutValidationErrors,
} from "./checkout-generator";
import type { PriceRow } from "./types";
type CheckoutGeneratorProps = {
initialCountry?: string;
initialCurrency?: string;
countries: PriceRow[];
onBack: () => void;
};
const commonCurrencies = ["USD", "EUR", "GBP", "SGD", "EGP"];
export default function CheckoutGenerator({
initialCountry = DEFAULT_CHECKOUT_COUNTRY,
initialCurrency = DEFAULT_CHECKOUT_CURRENCY,
countries,
onBack,
}: CheckoutGeneratorProps) {
const [coupon, setCoupon] = useState("");
const [country, setCountry] = useState(initialCountry);
const [currency, setCurrency] = useState(initialCurrency);
const [errors, setErrors] = useState<CheckoutValidationErrors>({});
const [notice, setNotice] = useState("");
const [copied, setCopied] = useState(false);
const couponRef = useRef<HTMLInputElement>(null);
const countryRef = useRef<HTMLInputElement>(null);
const currencyRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setCountry(initialCountry || DEFAULT_CHECKOUT_COUNTRY);
setCurrency(initialCurrency || DEFAULT_CHECKOUT_CURRENCY);
setErrors({});
}, [initialCountry, initialCurrency]);
useEffect(() => {
if (!notice) return undefined;
const timer = window.setTimeout(() => setNotice(""), 2600);
return () => window.clearTimeout(timer);
}, [notice]);
const normalized: CheckoutScriptInput = {
coupon: coupon.trim(),
country: country.toUpperCase(),
currency: currency.toUpperCase(),
};
const previewInput: CheckoutScriptInput = {
coupon: normalized.coupon || "XXXXXXXXXXXX",
country: /^[A-Z]{2}$/.test(normalized.country)
? normalized.country
: DEFAULT_CHECKOUT_COUNTRY,
currency: isSupportedCheckoutCurrency(normalized.currency)
? normalized.currency
: DEFAULT_CHECKOUT_CURRENCY,
};
const source = useMemo(
() => generateCheckoutScript(previewInput),
[previewInput.coupon, previewInput.country, previewInput.currency],
);
const isInputValid = Object.keys(validateCheckoutInput(normalized)).length === 0;
const currencyName = CHECKOUT_CURRENCIES.find(([code]) => code === normalized.currency)?.[1];
const clearError = (field: keyof CheckoutScriptInput) => {
if (!errors[field]) return;
setErrors((current) => ({ ...current, [field]: undefined }));
};
const validateAndFocus = () => {
const nextErrors = validateCheckoutInput(normalized);
setErrors(nextErrors);
const firstError = Object.keys(nextErrors)[0] as keyof CheckoutScriptInput | undefined;
if (firstError === "coupon") couponRef.current?.focus();
if (firstError === "country") countryRef.current?.focus();
if (firstError === "currency") currencyRef.current?.focus();
return !firstError;
};
const handleGenerate = (event: React.FormEvent) => {
event.preventDefault();
if (!validateAndFocus()) {
setNotice("请检查标红的参数");
return;
}
document.querySelector("#script-result")?.scrollIntoView({ behavior: "smooth", block: "start" });
setNotice("代码已按当前参数生成");
};
const copyCode = async () => {
if (!validateAndFocus()) {
setNotice("请先完成必填参数");
return;
}
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard API unavailable");
await navigator.clipboard.writeText(source);
} catch {
const textarea = document.createElement("textarea");
textarea.value = source;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand?.("copy");
textarea.remove();
}
setCopied(true);
setNotice("完整代码已复制到剪贴板");
window.setTimeout(() => setCopied(false), 1800);
};
const downloadCode = () => {
if (!validateAndFocus()) {
setNotice("请先完成必填参数");
return;
}
const blob = new Blob([source], { type: "text/javascript;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "chatgpt-team-checkout.js";
link.click();
URL.revokeObjectURL(url);
setNotice("代码文件已下载");
};
const reset = () => {
setCoupon("");
setCountry(DEFAULT_CHECKOUT_COUNTRY);
setCurrency(DEFAULT_CHECKOUT_CURRENCY);
setErrors({});
setNotice("已恢复默认值 US / EGP");
couponRef.current?.focus();
};
return (
<div className="generator-page">
<section className="generator-hero">
<div>
<button className="back-link" type="button" onClick={onBack}>
<ArrowLeft size={15} />
</button>
<span className="eyebrow"><Code2 size={15} /> BUSINESS TOOL · GENERATOR</span>
<h1><br /><em></em></h1>
<p> ChatGPT Team </p>
</div>
<div className="generator-promise">
<ShieldCheck size={22} />
<div><strong></strong><span></span></div>
</div>
</section>
<section className="generator-workspace" aria-labelledby="generator-title">
<div className="generator-form-panel">
<div className="generator-section-head">
<div><span className="section-kicker">INPUT PARAMETERS</span><h2 id="generator-title"></h2></div>
<span className="live-preview"><span /> </span>
</div>
<form onSubmit={handleGenerate} noValidate>
<label className={`generator-field ${errors.coupon ? "has-error" : ""}`}>
<span><strong></strong><small></small></span>
<input
ref={couponRef}
value={coupon}
onChange={(event) => { setCoupon(event.target.value); clearError("coupon"); }}
placeholder="例如:XXXXXXXXXXXX"
autoComplete="off"
spellCheck={false}
aria-invalid={Boolean(errors.coupon)}
/>
{errors.coupon ? <em role="alert">{errors.coupon}</em> : null}
</label>
<div className="generator-field-grid">
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
<span><strong> ISO </strong><small>2 </small></span>
<div className="iso-input"><b>ISO</b><input
ref={countryRef}
value={country}
onChange={(event) => { setCountry(normalizeIsoInput(event.target.value, 2)); clearError("country"); }}
maxLength={2}
list="checkout-country-list"
autoComplete="country"
spellCheck={false}
aria-invalid={Boolean(errors.country)}
/></div>
<datalist id="checkout-country-list">
{countries.map((row) => <option key={row.countryCode} value={row.countryCode}>{row.countryName}</option>)}
</datalist>
{errors.country ? <em role="alert">{errors.country}</em> : <small className="field-note"> CNUSKE</small>}
</label>
<label className={`generator-field ${errors.currency ? "has-error" : ""}`}>
<span><strong> ISO </strong><small>39 </small></span>
<div className="iso-input"><b>ISO</b><input
ref={currencyRef}
value={currency}
onChange={(event) => { setCurrency(normalizeIsoInput(event.target.value, 3)); clearError("currency"); }}
maxLength={3}
list="checkout-currency-list"
autoComplete="off"
spellCheck={false}
aria-invalid={Boolean(errors.currency)}
/></div>
<datalist id="checkout-currency-list">
{CHECKOUT_CURRENCIES.map(([code, name]) => <option key={code} value={code}>{name}</option>)}
</datalist>
{errors.currency ? <em role="alert">{errors.currency}</em> : <small className="field-note">{currencyName ? `${normalized.currency} · ${currencyName}` : "输入或选择货币代码"}</small>}
</label>
</div>
<div className="quick-currency-row" aria-label="常用货币快捷选择">
<span></span>
{commonCurrencies.map((code) => (
<button
key={code}
type="button"
className={normalized.currency === code ? "active" : ""}
onClick={() => { setCurrency(code); clearError("currency"); }}
>{code}</button>
))}
</div>
<button className="generate-button" type="submit"><Sparkles size={17} /> <span></span></button>
</form>
<div className="generator-steps">
<span>使</span>
<ol>
<li> ChatGPT </li>
<li></li>
<li> Stripe </li>
</ol>
</div>
</div>
<div className="generator-result-panel" id="script-result">
<div className="generator-section-head result-head">
<div><span className="section-kicker">OUTPUT</span><h2>JavaScript</h2></div>
<div className="code-actions">
<button type="button" onClick={downloadCode} aria-disabled={!isInputValid}><Download size={14} /> </button>
<button className="copy-code" type="button" onClick={copyCode} aria-disabled={!isInputValid}>
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? "已复制" : "复制代码"}
</button>
</div>
</div>
<div className="code-preview">
<div className="code-titlebar"><span /><span /><span /><small>team-checkout.js</small></div>
<pre><code>{source}</code></pre>
</div>
<div className="result-meta">
<span><Info size={13} /> ChatGPT </span>
<button type="button" onClick={reset}><RotateCcw size={13} /> </button>
</div>
</div>
</section>
<section className="generator-disclaimer">
<Info size={18} />
<p><strong>使</strong> ChatGPT </p>
</section>
{notice ? <div className="generator-toast" role="status">{notice}</div> : null}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
CHECKOUT_CURRENCIES,
DEFAULT_CHECKOUT_COUNTRY,
DEFAULT_CHECKOUT_CURRENCY,
generateCheckoutScript,
isSupportedCheckoutCurrency,
normalizeIsoInput,
validateCheckoutInput,
} from "./checkout-generator";
describe("checkout script generator", () => {
it("keeps the requested defaults and exact supported currency list", () => {
expect(DEFAULT_CHECKOUT_COUNTRY).toBe("US");
expect(DEFAULT_CHECKOUT_CURRENCY).toBe("EGP");
expect(CHECKOUT_CURRENCIES).toHaveLength(39);
expect(isSupportedCheckoutCurrency("egp")).toBe(true);
expect(isSupportedCheckoutCurrency("TRY")).toBe(false);
});
it("normalizes ISO input and validates every field", () => {
expect(normalizeIsoInput(" u-s1 ", 2)).toBe("US");
expect(validateCheckoutInput({ coupon: "", country: "USA", currency: "TRY" }))
.toEqual({
coupon: "请输入优惠码",
country: "请输入 2 位英文字母国家代码",
currency: "请选择支持的货币代码",
});
});
it("substitutes and safely escapes all generated values", () => {
const script = generateCheckoutScript({
coupon: 'SAVE "20" & 更多',
country: "SG",
currency: "SGD",
});
expect(script).toContain('const COUPON = "SAVE \\"20\\" & 更多"');
expect(script).toContain('country: "SG"');
expect(script).toContain('currency: "SGD"');
expect(script).toContain("promo_code: COUPON");
expect(script).toContain("promoCode=SAVE%20%2220%22%20%26%20%E6%9B%B4%E5%A4%9A");
expect(() => new Function(script)).not.toThrow();
});
});
+162
View File
@@ -0,0 +1,162 @@
export const CHECKOUT_CURRENCIES = [
["USD", "美元"],
["AUD", "澳大利亚元(澳元)"],
["CAD", "加拿大元(加元)"],
["GBP", "英镑"],
["EUR", "欧元"],
["CLP", "智利比索"],
["JPY", "日元"],
["INR", "印度卢比"],
["IDR", "印尼盾(印度尼西亚卢比)"],
["PKR", "巴基斯坦卢比"],
["THB", "泰铢"],
["MYR", "马来西亚林吉特"],
["TWD", "新台币"],
["VND", "越南盾"],
["PHP", "菲律宾比索"],
["NGN", "尼日利亚奈拉"],
["ZAR", "南非兰特"],
["KZT", "哈萨克斯坦坚戈"],
["TZS", "坦桑尼亚先令"],
["EGP", "埃及镑"],
["BRL", "巴西雷亚尔"],
["SEK", "瑞典克朗"],
["CZK", "捷克克朗"],
["PLN", "波兰兹罗提"],
["DKK", "丹麦克朗"],
["NOK", "挪威克朗"],
["KRW", "韩元(韩国圆)"],
["COP", "哥伦比亚比索"],
["MXN", "墨西哥比索"],
["PEN", "秘鲁索尔"],
["HUF", "匈牙利福林"],
["QAR", "卡塔尔里亚尔"],
["RON", "罗马尼亚列伊"],
["ILS", "以色列新谢克尔"],
["AED", "阿联酋迪拉姆"],
["SGD", "新加坡元(新元)"],
["NZD", "新西兰元(纽元)"],
["CHF", "瑞士法郎"],
["SAR", "沙特里亚尔"],
] as const;
export const DEFAULT_CHECKOUT_COUNTRY = "US";
export const DEFAULT_CHECKOUT_CURRENCY = "EGP";
const currencyCodes = new Set<string>(CHECKOUT_CURRENCIES.map(([code]) => code));
export type CheckoutScriptInput = {
coupon: string;
country: string;
currency: string;
};
export type CheckoutValidationErrors = Partial<Record<keyof CheckoutScriptInput, string>>;
export function normalizeIsoInput(value: string, maxLength: number): string {
return value.toUpperCase().replace(/[^A-Z]/g, "").slice(0, maxLength);
}
export function isSupportedCheckoutCurrency(value: string): boolean {
return currencyCodes.has(value.toUpperCase());
}
export function validateCheckoutInput(input: CheckoutScriptInput): CheckoutValidationErrors {
const errors: CheckoutValidationErrors = {};
if (!input.coupon.trim()) errors.coupon = "请输入优惠码";
if (!/^[A-Z]{2}$/.test(input.country)) errors.country = "请输入 2 位英文字母国家代码";
if (!isSupportedCheckoutCurrency(input.currency)) errors.currency = "请选择支持的货币代码";
return errors;
}
export function generateCheckoutScript(input: CheckoutScriptInput): string {
const coupon = input.coupon || "XXXXXXXXXXXX";
const country = input.country || DEFAULT_CHECKOUT_COUNTRY;
const currency = input.currency || DEFAULT_CHECKOUT_CURRENCY;
const encodedCoupon = encodeURIComponent(coupon);
return `(async function generateAUTeamLink() {
// ================= 配置项 =================
const WORKSPACE_NAME = "xxx";
const COUPON = ${JSON.stringify(coupon)}; // 优惠码
const SEAT_QUANTITY = 2; // 席位数量(Team 最少 2 个)
// ==========================================
console.log("⏳ 正在获取 ChatGPT Session Token...");
// 1. 自动获取登录凭证
let accessToken;
try {
const s = await fetch("/api/auth/session").then(r => r.json());
accessToken = s?.accessToken;
if (!accessToken) throw new Error("accessToken 为空,请确认已登录 ChatGPT 账号");
} catch (e) {
console.error("❌ 获取 Token 失败:", e.message);
return;
}
console.log("✅ Token 获取成功");
// 2. 构建请求 Payload
const payload = {
plan_name: "chatgptteamplan",
team_plan_data: {
workspace_name: WORKSPACE_NAME,
price_interval: "month", // month 或 year
seat_quantity: SEAT_QUANTITY
},
billing_details: {
country: ${JSON.stringify(country)},
currency: ${JSON.stringify(currency)}
},
cancel_url: "https://chatgpt.com/?promoCode=${encodedCoupon}",
promo_code: COUPON,
checkout_ui_mode: "hosted"
};
// 3. 请求 Stripe 长链接
console.log("⏳ 正在请求 Stripe 支付长链接...");
try {
const resp = await fetch(
"https://chatgpt.com/backend-api/payments/checkout",
{
method: "POST",
headers: {
Authorization: \`Bearer \${accessToken}\`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
const data = await resp.json();
if (!resp.ok) {
console.error(\`❌ 请求失败 HTTP \${resp.status}\`);
console.log("📋 响应详情:", data);
return;
}
const hostedUrl = data?.url || data?.stripe_hosted_url || data?.checkout_url;
if (!hostedUrl) {
console.warn("⚠️ 未找到长链接,原始响应:", data);
return;
}
// 4. 打印结果
console.log("─".repeat(60));
console.log("✅ ChatGPT Team 链接生成成功!");
console.log(\`📌 工作区名称 : \${payload.team_plan_data.workspace_name}\`);
console.log(\`💺 席位数量 : \${payload.team_plan_data.seat_quantity}\`);
console.log(\`🎟️ 优惠码 : \${COUPON}\`);
console.log(\`🌍 地区/货币 : \${payload.billing_details.country} (\${payload.billing_details.currency})\`);
if (data.checkout_session_id) {
console.log(\`🆔 Session ID : \${data.checkout_session_id}\`);
}
console.log("─".repeat(60));
console.log("🔗 Stripe 支付长链接(复制到浏览器打开):");
console.log(hostedUrl);
console.log("─".repeat(60));
} catch (e) {
console.error("❌ 网络异常或请求失败:", e.message);
}
})();`;
}
+105 -3
View File
@@ -23,14 +23,18 @@ 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; } 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; } .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; } .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); } .topbar { position: relative; 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 { display: inline-flex; align-items: center; gap: 12px; text-decoration: none; }
.brand-button { padding: 0; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.brand > span:last-child { display: flex; flex-direction: column; gap: 2px; } .brand > span:last-child { display: flex; flex-direction: column; gap: 2px; }
.brand strong { font-size: 15px; letter-spacing: -.01em; } .brand strong { font-size: 15px; letter-spacing: -.01em; }
.brand small { color: var(--muted); font-size: 10px; letter-spacing: .13em; text-transform: uppercase; } .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 { 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::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); } .brand-mark span { position: absolute; width: 5px; height: 5px; background: var(--lime); border-radius: 50%; transform: translate(5px, -5px); }
.tool-navigation { position: absolute; left: 50%; display: inline-flex; padding: 4px; border: 1px solid var(--line); border-radius: 13px; background: rgba(255,255,255,.78); transform: translateX(-50%); box-shadow: 0 6px 24px rgba(30,54,45,.04); }
.tool-navigation button { display: inline-flex; align-items: center; gap: 7px; padding: 9px 13px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 750; cursor: pointer; white-space: nowrap; }
.tool-navigation button.active { background: var(--ink); color: white; }
.topbar-actions { display: flex; align-items: center; gap: 18px; } .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; } .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); } .live-dot { width: 7px; height: 7px; border-radius: 50%; background: #2ca675; box-shadow: 0 0 0 4px rgba(44,166,117,.12); }
@@ -92,7 +96,7 @@ main { width: min(1440px, calc(100% - 64px)); margin: 0 auto; }
.sort-control select { color: white; } .sort-control select { color: white; }
.sort-control option { color: var(--ink); } .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-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; } table { width: 100%; min-width: 1160px; 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; } 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; } 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:last-child td { border-bottom: 0; }
@@ -116,6 +120,10 @@ tbody tr:hover { background: #f9fbf8; }
.updated-time { display: block; margin-top: 5px; color: #9aa39f; font-size: 9px; } .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 { 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); } .source-link:hover { background: var(--green); border-color: var(--green); color: white; transform: translateY(-1px); }
.row-actions { display: flex; align-items: center; gap: 7px; }
.generate-row-button { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; border: 1px solid #cbdacf; border-radius: 9px; background: #f4f8f2; color: var(--green); font-size: 10px; font-weight: 750; cursor: pointer; white-space: nowrap; transition: .18s ease; }
.generate-row-button:hover { border-color: var(--green); background: var(--green); color: white; transform: translateY(-1px); }
.generate-row-button:disabled { border-color: var(--line); background: #f3f4f2; color: #a8afac; cursor: not-allowed; transform: none; }
.price-cards { display: none; } .price-cards { display: none; }
.loading-rows { border: 1px solid var(--line); border-radius: 18px; background: white; overflow: hidden; } .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 { 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; }
@@ -133,6 +141,73 @@ tbody tr:hover { background: #f9fbf8; }
.disclaimer p { margin: 0; font-size: 11px; line-height: 1.8; } .disclaimer p { margin: 0; font-size: 11px; line-height: 1.8; }
.disclaimer strong { color: var(--ink); } .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; } .disclaimer a { display: inline-flex; align-items: center; gap: 3px; margin-left: 5px; color: var(--green); font-weight: 700; text-decoration: none; }
.generator-page { padding-top: 56px; }
.generator-hero { min-height: 330px; display: grid; grid-template-columns: 1.25fr .75fr; gap: 70px; align-items: center; }
.generator-hero > div:first-child { max-width: 820px; }
.back-link { display: flex; width: fit-content; align-items: center; gap: 6px; margin-bottom: 30px; padding: 0 0 6px; border: 0; border-bottom: 1px solid #b7c3bc; background: transparent; color: var(--muted); font-size: 10px; font-weight: 700; cursor: pointer; }
.generator-hero h1 { margin: 24px 0 18px; font-family: Georgia, "Noto Serif SC", serif; font-size: clamp(46px, 5vw, 72px); line-height: 1; letter-spacing: -.055em; font-weight: 500; }
.generator-hero h1 em { color: var(--green); font-style: normal; }
.generator-hero > div:first-child > p { max-width: 690px; margin: 0; color: var(--muted); font-size: 15px; line-height: 1.85; }
.generator-promise { display: flex; align-items: flex-start; gap: 13px; padding: 22px; border: 1px solid #cfdacf; border-radius: 18px; background: rgba(255,255,255,.68); color: var(--green); box-shadow: 0 15px 40px rgba(28,54,43,.05); }
.generator-promise div { display: flex; flex-direction: column; gap: 6px; }
.generator-promise strong { color: var(--ink); font-size: 13px; }
.generator-promise span { color: var(--muted); font-size: 11px; line-height: 1.65; }
.generator-workspace { display: grid; grid-template-columns: minmax(370px, .8fr) minmax(0, 1.2fr); border: 1px solid var(--line); border-radius: 24px; background: rgba(255,255,255,.78); box-shadow: 0 24px 70px rgba(21,48,37,.07); overflow: hidden; }
.generator-form-panel, .generator-result-panel { min-width: 0; padding: 34px; }
.generator-form-panel { border-right: 1px solid var(--line); }
.generator-result-panel { background: #f0f3ef; }
.generator-section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.generator-section-head h2 { margin: 7px 0 0; font-family: Georgia, "Noto Serif SC", serif; font-size: 28px; font-weight: 600; letter-spacing: -.03em; }
.live-preview { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 9px; font-weight: 700; }
.live-preview > span { width: 7px; height: 7px; border-radius: 50%; background: #2ca675; box-shadow: 0 0 0 4px rgba(44,166,117,.12); }
.generator-form-panel form { margin-top: 28px; }
.generator-field { display: block; margin-bottom: 18px; }
.generator-field > span { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.generator-field > span strong { font-size: 12px; }
.generator-field > span small { color: #8a9490; font-size: 9px; font-weight: 700; text-transform: uppercase; }
.generator-field > input, .iso-input { width: 100%; height: 49px; border: 1px solid #d7ddd7; border-radius: 11px; background: white; transition: border-color .18s ease, box-shadow .18s ease; }
.generator-field > input { padding: 0 13px; color: var(--ink); outline: 0; }
.generator-field > input:focus, .iso-input:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23,107,77,.1); }
.generator-field.has-error > input, .generator-field.has-error .iso-input { border-color: #bd5b4f; }
.generator-field > em { display: block; margin-top: 7px; color: #a74338; font-size: 9px; font-style: normal; }
.generator-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.iso-input { display: grid; grid-template-columns: auto 1fr; align-items: center; }
.iso-input b { padding-left: 12px; color: #87918d; font: 700 9px/1 monospace; }
.iso-input input { width: 100%; height: 47px; min-width: 0; padding: 0 12px; border: 0; outline: 0; background: transparent; color: var(--ink); font: 750 15px/1 monospace; letter-spacing: .12em; text-transform: uppercase; }
.field-note { display: block; min-height: 14px; margin-top: 7px; color: #87918d; font-size: 9px; }
.quick-currency-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: -3px; }
.quick-currency-row > span { margin-right: 3px; color: var(--muted); font-size: 9px; }
.quick-currency-row button { padding: 5px 7px; border: 1px solid #d2d9d3; border-radius: 7px; background: white; color: var(--muted); font: 700 9px/1 monospace; cursor: pointer; }
.quick-currency-row button.active, .quick-currency-row button:hover { border-color: var(--green); background: var(--soft); color: var(--green); }
.generate-button { display: flex; width: 100%; align-items: center; gap: 8px; margin-top: 24px; padding: 15px 16px; border: 0; border-radius: 11px; background: var(--ink); color: white; font-size: 12px; font-weight: 750; cursor: pointer; box-shadow: 0 9px 22px rgba(23,32,29,.13); transition: transform .18s ease, background .18s ease; }
.generate-button span { margin-left: auto; }
.generate-button:hover { background: var(--green-dark); transform: translateY(-1px); }
.generator-steps { margin-top: 28px; padding-top: 22px; border-top: 1px solid var(--line); }
.generator-steps > span { color: var(--green); font: 800 9px/1 monospace; letter-spacing: .1em; text-transform: uppercase; }
.generator-steps ol { margin: 12px 0 0; padding-left: 19px; color: var(--muted); font-size: 10px; line-height: 1.9; }
.result-head { align-items: center; }
.code-actions { display: flex; align-items: center; gap: 7px; }
.code-actions button { display: inline-flex; height: 35px; align-items: center; gap: 6px; padding: 0 10px; border: 1px solid #cbd3cc; border-radius: 9px; background: white; color: var(--ink); font-size: 9px; font-weight: 750; cursor: pointer; }
.code-actions .copy-code { border-color: var(--green); background: var(--green); color: white; }
.code-actions button[aria-disabled="true"] { opacity: .52; }
.code-actions button:hover { transform: translateY(-1px); }
.code-preview { margin-top: 20px; overflow: hidden; border-radius: 16px; background: #151b18; box-shadow: 0 18px 45px rgba(20,30,26,.17); }
.code-titlebar { display: grid; grid-template-columns: 8px 8px 8px 1fr; align-items: center; gap: 6px; height: 39px; padding: 0 14px; border-bottom: 1px solid rgba(255,255,255,.08); background: #1e2521; }
.code-titlebar > span { width: 7px; height: 7px; border-radius: 50%; background: #ef6a5b; }
.code-titlebar > span:nth-child(2) { background: #e7b94f; }
.code-titlebar > span:nth-child(3) { background: #65bd70; }
.code-titlebar small { margin-left: 6px; color: #748079; font: 500 9px/1 monospace; }
.code-preview pre { max-height: 570px; margin: 0; overflow: auto; padding: 20px; scrollbar-color: #48534c #151b18; }
.code-preview code { color: #dfe9df; font: 11px/1.7 "SFMono-Regular", Consolas, "Liberation Mono", monospace; white-space: pre; tab-size: 2; }
.result-meta { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; color: #77817c; font-size: 9px; }
.result-meta span, .result-meta button { display: inline-flex; align-items: center; gap: 5px; }
.result-meta button { padding: 0 0 4px; border: 0; border-bottom: 1px solid #bdc6bf; background: transparent; color: #67726d; font-size: 9px; cursor: pointer; }
.generator-disclaimer { display: flex; align-items: flex-start; gap: 12px; margin-top: 20px; padding: 18px 20px; border-radius: 14px; background: #ebefea; color: #66716c; }
.generator-disclaimer svg { flex: 0 0 auto; margin-top: 1px; }
.generator-disclaimer p { margin: 0; font-size: 10px; line-height: 1.8; }
.generator-disclaimer strong { color: var(--ink); }
.generator-toast { position: fixed; right: 24px; bottom: 24px; z-index: 20; padding: 12px 15px; border: 1px solid #aabea9; border-radius: 10px; background: var(--lime); color: var(--ink); font-size: 10px; font-weight: 750; box-shadow: 0 12px 30px rgba(23,32,29,.16); }
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 { 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 { width: 30px; height: 30px; }
.footer-brand .brand-mark::before { width: 14px; height: 14px; } .footer-brand .brand-mark::before { width: 14px; height: 14px; }
@@ -141,20 +216,28 @@ footer > span:last-child { text-align: right; }
@keyframes shimmer { to { background-position: -200% 0; } } @keyframes shimmer { to { background-position: -200% 0; } }
@media (max-width: 1040px) { @media (max-width: 1040px) {
.source-pill { display: none; }
.topbar-actions { gap: 8px; }
.hero { grid-template-columns: 1fr .8fr; gap: 36px; } .hero { grid-template-columns: 1fr .8fr; gap: 36px; }
.metrics { grid-template-columns: repeat(2, 1fr); } .metrics { grid-template-columns: repeat(2, 1fr); }
.metric-card:nth-child(2) { border-right: 0; } .metric-card:nth-child(2) { border-right: 0; }
.metric-card:nth-child(-n+2) { border-bottom: 1px solid var(--line); } .metric-card:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.controls { grid-template-columns: 1fr 1fr 1fr; } .controls { grid-template-columns: 1fr 1fr 1fr; }
.search-control { grid-column: 1 / -1; } .search-control { grid-column: 1 / -1; }
.generator-hero { gap: 36px; }
.generator-workspace { grid-template-columns: minmax(340px, .9fr) minmax(0, 1.1fr); }
.generator-form-panel, .generator-result-panel { padding: 28px; }
.generator-field-grid { grid-template-columns: 1fr; gap: 0; }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.topbar, main, footer { width: min(100% - 32px, 680px); } .topbar, main, footer { width: min(100% - 32px, 680px); }
.topbar { height: 74px; } .topbar { height: auto; min-height: 74px; flex-wrap: wrap; gap: 11px; padding: 13px 0; }
.topbar-actions { gap: 8px; } .topbar-actions { gap: 8px; }
.source-pill { display: none; } .source-pill { display: none; }
.brand small { display: none; } .brand small { display: none; }
.tool-navigation { position: static; order: 3; width: 100%; transform: none; }
.tool-navigation button { flex: 1; justify-content: center; }
.github-badge { width: 36px; height: 36px; padding: 0; } .github-badge { width: 36px; height: 36px; padding: 0; }
.github-badge span { display: none; } .github-badge span { display: none; }
.currency-toggle button { padding: 7px 9px; font-size: 10px; } .currency-toggle button { padding: 7px 9px; font-size: 10px; }
@@ -187,6 +270,18 @@ footer > span:last-child { text-align: right; }
.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 { 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; } .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; } .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; }
.mobile-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 18px; }
.mobile-card-actions button, .mobile-card-actions a { display: inline-flex; min-height: 38px; align-items: center; justify-content: center; gap: 6px; margin: 0; border: 1px solid #cbdacf; border-radius: 9px; background: #f4f8f2; color: var(--green); font-size: 10px; font-weight: 750; text-decoration: none; }
.mobile-card-actions button { cursor: pointer; }
.mobile-card-actions button:disabled { color: #a8afac; cursor: not-allowed; }
.generator-page { padding-top: 40px; }
.generator-hero { min-height: 0; grid-template-columns: 1fr; gap: 26px; padding-bottom: 42px; }
.generator-hero h1 { font-size: clamp(43px, 13vw, 62px); }
.generator-hero > div:first-child > p { font-size: 13px; }
.generator-workspace { grid-template-columns: 1fr; }
.generator-form-panel { border-right: 0; border-bottom: 1px solid var(--line); }
.generator-field-grid { grid-template-columns: 1fr 1fr; gap: 12px; }
.code-preview pre { max-height: 480px; }
.explainers { grid-template-columns: 1fr; margin-top: 64px; } .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, .explainers article + article { padding: 28px 0; border-right: 0; border-bottom: 1px solid var(--line); }
.explainers article:last-child { border-bottom: 0; } .explainers article:last-child { border-bottom: 0; }
@@ -207,4 +302,11 @@ footer > span:last-child { text-align: right; }
.controls { grid-template-columns: 1fr; } .controls { grid-template-columns: 1fr; }
.search-control, .sort-control { grid-column: auto; } .search-control, .sort-control { grid-column: auto; }
.section-heading p { display: none; } .section-heading p { display: none; }
.generator-form-panel, .generator-result-panel { padding: 22px 16px; }
.generator-field-grid { grid-template-columns: 1fr; gap: 0; }
.result-head { align-items: flex-end; }
.code-actions button:first-child { display: none; }
.code-preview pre { padding: 16px; }
.code-preview code { font-size: 10px; }
.result-meta { align-items: flex-start; }
} }
+2
View File
@@ -1,2 +1,4 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
Object.defineProperty(window, "scrollTo", { value: () => undefined, writable: true });
Object.defineProperty(Element.prototype, "scrollIntoView", { value: () => undefined, writable: true });