up
This commit is contained in:
@@ -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 个有效地区。
|
||||
|
||||
+12
-4
@@ -6,15 +6,23 @@
|
||||
<meta name="theme-color" content="#f5f7f3" />
|
||||
<meta
|
||||
name="description"
|
||||
content="查看 ChatGPT Business 在不同国家和地区的官方月付价格,并换算为人民币或美元。"
|
||||
content="查询 ChatGPT Business 全球月付价格,并按国家、货币和优惠码生成结账脚本。"
|
||||
/>
|
||||
<meta property="og:title" content="Business Price Radar|ChatGPT Business 全球月付价格" />
|
||||
<meta property="og:title" content="Business Toolkit|全球月付价格与脚本生成器" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="独立的 ChatGPT Business 地区月价查询与汇率换算工具。"
|
||||
content="独立的 ChatGPT Business 地区月价查询、汇率换算与本地脚本生成工具。"
|
||||
/>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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(<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¤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(<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
@@ -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<PricingSnapshot> {
|
||||
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<AppRoute>(readRoute);
|
||||
const [snapshot, setSnapshot] = useState<PricingSnapshot | null>(null);
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null);
|
||||
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(() => {
|
||||
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<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 (
|
||||
<div className="app-shell">
|
||||
<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 />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Business Price Radar</strong>
|
||||
<small>全球月付价格</small>
|
||||
<strong>Business Toolkit</strong>
|
||||
<small>价格与结账工具</small>
|
||||
</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">
|
||||
<span className="source-pill">
|
||||
<span className="live-dot" /> OpenAI 公开配置
|
||||
</span>
|
||||
{route.view === "prices" ? <span className="source-pill"><span className="live-dot" /> OpenAI 公开配置</span> : null}
|
||||
<a
|
||||
className="github-badge"
|
||||
href="https://github.com/zhangkaihua88/chatgpt-business-price-radar"
|
||||
@@ -130,11 +183,13 @@ export default function App() {
|
||||
<Github size={16} aria-hidden="true" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<CurrencyToggle value={currency} onChange={setDisplayCurrency} />
|
||||
{route.view === "prices" ? <CurrencyToggle value={currency} onChange={setDisplayCurrency} /> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{route.view === "prices" ? (
|
||||
<>
|
||||
<section className="hero">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow"><CircleDollarSign size={15} /> CHATGPT BUSINESS · MONTHLY</span>
|
||||
@@ -266,8 +321,8 @@ export default function App() {
|
||||
{!snapshot && !loadingError ? <LoadingRows /> : null}
|
||||
{snapshot && rows.length ? (
|
||||
<>
|
||||
<PriceTable rows={rows} currency={currency} />
|
||||
<PriceCards rows={rows} currency={currency} />
|
||||
<PriceTable rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row)} />
|
||||
<PriceCards rows={rows} currency={currency} onGenerate={(row) => navigate("generator", row)} />
|
||||
</>
|
||||
) : null}
|
||||
{snapshot && !rows.length ? (
|
||||
@@ -279,7 +334,7 @@ export default function App() {
|
||||
<article>
|
||||
<span className="explainer-number">01</span>
|
||||
<h3>价格从哪里来?</h3>
|
||||
<p>报价来自 chatgpt.com 的公开结账价格配置接口。本站只读取 Business 月付字段,不参与购买。</p>
|
||||
<p>报价来自 chatgpt.com 的公开结账价格配置接口。价格雷达只读取 Business 月付字段。</p>
|
||||
</article>
|
||||
<article>
|
||||
<span className="explainer-number">02</span>
|
||||
@@ -296,17 +351,26 @@ export default function App() {
|
||||
<section className="disclaimer">
|
||||
<Info size={19} />
|
||||
<p>
|
||||
<strong>独立信息工具,非 OpenAI 官方产品。</strong>
|
||||
实际价格、税费、付款资格与地区可用性以结账页为准。ChatGPT Business 按用户计费,标准席位至少购买 2 个。
|
||||
<strong>独立工具,非 OpenAI 官方产品。</strong>
|
||||
价格数据来自公开配置;脚本仅在浏览器本地生成,不会由本站执行。实际价格、税费、付款资格与地区可用性以结账页为准。
|
||||
<a href="https://help.openai.com/en/articles/8792536" target="_blank" rel="noreferrer">查看官方账单说明 <ArrowUpRight size={14} /></a>
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<CheckoutGenerator
|
||||
initialCountry={route.country}
|
||||
initialCurrency={route.currency}
|
||||
countries={snapshot?.rows ?? []}
|
||||
onBack={() => navigate("prices")}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<span><strong>Business Toolkit</strong><small>Independent pricing & checkout tools</small></span>
|
||||
</div>
|
||||
<p>ChatGPT 与 OpenAI 为其各自权利人的商标。本站与 OpenAI 无隶属或背书关系。</p>
|
||||
<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 (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
@@ -368,7 +440,7 @@ function PriceTable({ rows, currency }: { rows: PriceRow[]; currency: DisplayCur
|
||||
<th scope="col">折合 {currency}</th>
|
||||
<th scope="col">税费口径</th>
|
||||
<th scope="col">更新时间</th>
|
||||
<th scope="col"><span className="sr-only">来源</span></th>
|
||||
<th scope="col">快捷操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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><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>
|
||||
<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}官方价格源`}>
|
||||
<ArrowUpRight size={17} />
|
||||
<ArrowUpRight size={16} />
|
||||
</a>
|
||||
</td>
|
||||
</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 (
|
||||
<div className="price-cards">
|
||||
{rows.map((row, index) => (
|
||||
@@ -416,7 +506,15 @@ function PriceCards({ rows, currency }: { rows: PriceRow[]; currency: DisplayCur
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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">例如 CN、US、KE</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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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; }
|
||||
.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); }
|
||||
.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-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 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); }
|
||||
.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; }
|
||||
.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); }
|
||||
@@ -92,7 +96,7 @@ main { width: min(1440px, calc(100% - 64px)); margin: 0 auto; }
|
||||
.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; }
|
||||
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; }
|
||||
td { padding: 18px 20px; border-bottom: 1px solid #edf0ed; font-size: 12px; vertical-align: middle; }
|
||||
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; }
|
||||
.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); }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -133,6 +141,73 @@ tbody tr:hover { background: #f9fbf8; }
|
||||
.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; }
|
||||
|
||||
.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-brand .brand-mark { width: 30px; height: 30px; }
|
||||
.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; } }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.source-pill { display: none; }
|
||||
.topbar-actions { gap: 8px; }
|
||||
.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; }
|
||||
.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) {
|
||||
.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; }
|
||||
.source-pill { 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 span { display: none; }
|
||||
.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 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; }
|
||||
.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 article, .explainers article + article { padding: 28px 0; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.explainers article:last-child { border-bottom: 0; }
|
||||
@@ -207,4 +302,11 @@ footer > span:last-child { text-align: right; }
|
||||
.controls { grid-template-columns: 1fr; }
|
||||
.search-control, .sort-control { grid-column: auto; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
Object.defineProperty(window, "scrollTo", { value: () => undefined, writable: true });
|
||||
Object.defineProperty(Element.prototype, "scrollIntoView", { value: () => undefined, writable: true });
|
||||
|
||||
Reference in New Issue
Block a user