feat(website): 本地联调 /buy 测试购买页(登录→下单→mock 付款→轮询开通)+ dev vite proxy
This commit is contained in:
@@ -24,5 +24,28 @@ export default defineConfig({
|
||||
// 关闭 JS 内联,保证 script-src 'self' 严格 CSP 下可运行。
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
server: {
|
||||
// /buy 本地联调测试页专用:浏览器同源调后端,避免跨域。仅 dev 生效,不影响构建产物。
|
||||
//
|
||||
// 坑:Astro 全站 trailingSlash:'always',dev server 对不带尾斜杠、又没有匹配页面路由
|
||||
// 的路径(如 /v1/pay/catalog)会在到达这里的 vite 代理之前就短路返回 Astro 自己的 404
|
||||
// (已用本地 mock 后端验证:带尾斜杠能命中此代理,不带则 404,Astro middleware 同样不触发,
|
||||
// 因为它只包裹「匹配到的」路由渲染,不包裹压根没有路由匹配的任意路径)。
|
||||
// 于是这里约定:BuyFlow.jsx 发请求一律带尾斜杠(`/v1/pay/catalog/` 这类,与全站
|
||||
// trailingSlash 约定一致),再用 rewrite 在转发前把尾斜杠去掉,避免打到后端 chi 路由
|
||||
// (未挂 StripSlashes,尾斜杠会 404)。
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/\/$/, ''),
|
||||
},
|
||||
'/paydev': {
|
||||
target: 'http://127.0.0.1:8090',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/paydev/, '/api/v2/dev').replace(/\/$/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* BuyFlow.jsx — /buy 本地联调测试页的交互状态机(React island,client:load)。
|
||||
*
|
||||
* ⚠️ 仅供本地联调,不是真实购买流程;不接入 i18n 字典,不参与生产首页。
|
||||
* 状态机:login → catalog → awaiting(下单后轮询) → activated / failed。
|
||||
*
|
||||
* 后端契约(server/internal/pay/handler.go,已核对,见任务描述):
|
||||
* POST /v1/auth/login → { access_token, refresh_token, expires_in, ... }
|
||||
* GET /v1/pay/catalog → { items:[{ sku, plan, days, price_minor, currency }] }
|
||||
* POST /v1/pay/orders → { order_no, session:{ render_type, payload, expires_at? } }
|
||||
* GET /v1/pay/orders/{no} → { order_no, pay_status, activated, expires_at? }
|
||||
* POST /paydev/orders/{no}/mark-paid → 200(本地 dev-only,经 vite proxy 转发到 pay 的 dev 端点)
|
||||
*
|
||||
* API base:同源相对路径,留 PUBLIC_API_BASE 口子(默认空 = 同源,走 astro.config.mjs 的 vite proxy)。
|
||||
*
|
||||
* 坑(astro.config.mjs 里也记了一遍):全站 trailingSlash:'always',dev server 对不带尾斜杠又
|
||||
* 没匹配到页面路由的路径会在到达 vite 代理前被 Astro 自己短路 404,所以这里请求一律带尾斜杠
|
||||
* (与站点约定一致),astro.config.mjs 的 proxy rewrite 会在转发前把尾斜杠去掉再打后端 chi 路由。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
QrCode,
|
||||
RotateCcw,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
|
||||
const API_BASE = import.meta.env.PUBLIC_API_BASE || '';
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(API_BASE + path, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(body && typeof body === 'object' && (body.error || body.message)) ||
|
||||
(typeof body === 'string' && body) ||
|
||||
`HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function fmtPrice(minor, currency) {
|
||||
const v = (minor ?? 0) / 100;
|
||||
return `${v.toFixed(2)} ${currency || ''}`.trim();
|
||||
}
|
||||
|
||||
export default function BuyFlow() {
|
||||
const [step, setStep] = useState('login'); // login | catalog | awaiting | activated
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
// login
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState('');
|
||||
|
||||
// catalog
|
||||
const [items, setItems] = useState([]);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [catalogError, setCatalogError] = useState('');
|
||||
|
||||
// order
|
||||
const [orderLoading, setOrderLoading] = useState('');
|
||||
const [orderError, setOrderError] = useState('');
|
||||
const [order, setOrder] = useState(null); // { order_no, session }
|
||||
const [payStatus, setPayStatus] = useState(null); // { pay_status, activated }
|
||||
const [pollError, setPollError] = useState('');
|
||||
|
||||
// mock pay
|
||||
const [markPaidLoading, setMarkPaidLoading] = useState(false);
|
||||
const [markPaidError, setMarkPaidError] = useState('');
|
||||
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => stopPolling, []); // unmount 兜底清 interval
|
||||
|
||||
async function fetchCatalog(bearer) {
|
||||
setCatalogLoading(true);
|
||||
setCatalogError('');
|
||||
try {
|
||||
const data = await api('/v1/pay/catalog/', {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
});
|
||||
setItems(data?.items || []);
|
||||
} catch (e) {
|
||||
setCatalogError(e.message || String(e));
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogin(e) {
|
||||
e.preventDefault();
|
||||
setLoginError('');
|
||||
setLoginLoading(true);
|
||||
try {
|
||||
const data = await api('/v1/auth/login/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
device: {
|
||||
id: 'web-buy',
|
||||
name: 'web',
|
||||
platform: 'web',
|
||||
client_version: '0',
|
||||
},
|
||||
}),
|
||||
});
|
||||
setToken(data.access_token);
|
||||
setStep('catalog');
|
||||
fetchCatalog(data.access_token);
|
||||
} catch (e) {
|
||||
setLoginError(e.message || String(e));
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onPickSku(sku) {
|
||||
setOrderError('');
|
||||
setOrderLoading(sku);
|
||||
try {
|
||||
const data = await api('/v1/pay/orders/', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
sku,
|
||||
method: 'fake',
|
||||
metadata: { is_mobile: false },
|
||||
}),
|
||||
});
|
||||
setOrder(data);
|
||||
setPayStatus(null);
|
||||
setPollError('');
|
||||
setStep('awaiting');
|
||||
startPolling(data.order_no);
|
||||
} catch (e) {
|
||||
setOrderError(e.message || String(e));
|
||||
} finally {
|
||||
setOrderLoading('');
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(orderNo) {
|
||||
stopPolling();
|
||||
const tick = async () => {
|
||||
try {
|
||||
const data = await api(`/v1/pay/orders/${orderNo}/`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
setPayStatus(data);
|
||||
setPollError('');
|
||||
if (data && data.activated === true) {
|
||||
stopPolling();
|
||||
setStep('activated');
|
||||
}
|
||||
} catch (e) {
|
||||
setPollError(e.message || String(e));
|
||||
}
|
||||
};
|
||||
tick();
|
||||
pollRef.current = setInterval(tick, 2500);
|
||||
}
|
||||
|
||||
async function onMarkPaid() {
|
||||
if (!order) return;
|
||||
setMarkPaidError('');
|
||||
setMarkPaidLoading(true);
|
||||
try {
|
||||
await api(`/paydev/orders/${order.order_no}/mark-paid/`, { method: 'POST' });
|
||||
// 不主动切状态,靠轮询自然把 activated 翻 true。
|
||||
} catch (e) {
|
||||
setMarkPaidError(e.message || String(e));
|
||||
} finally {
|
||||
setMarkPaidLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
stopPolling();
|
||||
setOrder(null);
|
||||
setPayStatus(null);
|
||||
setOrderError('');
|
||||
setPollError('');
|
||||
setMarkPaidError('');
|
||||
setStep('catalog');
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="buy-card">
|
||||
{/* ---------- step: login ---------- */}
|
||||
{step === 'login' && (
|
||||
<form class="buy-block" onSubmit={onLogin}>
|
||||
<h2 class="buy-h">1. 登录测试账号</h2>
|
||||
<p class="buy-hint">用编排时注册好的账号邮箱 + 密码登录,仅本地联调使用。</p>
|
||||
<div class="su-row">
|
||||
<div class="su-field">
|
||||
<Mail />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="test@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="su-row" style={{ marginTop: '10px' }}>
|
||||
<div class="su-field">
|
||||
<Lock />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg su-btn" type="submit" disabled={loginLoading}>
|
||||
{loginLoading ? <Loader2 class="buy-spin" /> : null}
|
||||
<span>{loginLoading ? '登录中…' : '登录'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{loginError && (
|
||||
<div class="buy-err">
|
||||
<AlertCircle />
|
||||
<span>{loginError}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ---------- step: catalog ---------- */}
|
||||
{step === 'catalog' && (
|
||||
<div class="buy-block">
|
||||
<h2 class="buy-h">2. 选择套餐下单</h2>
|
||||
<p class="buy-hint">已登录,方法固定为 <code>fake</code>(本地 mock 渠道)。</p>
|
||||
|
||||
{catalogLoading && (
|
||||
<div class="buy-loading">
|
||||
<Loader2 class="buy-spin" />
|
||||
<span>加载套餐中…</span>
|
||||
</div>
|
||||
)}
|
||||
{catalogError && (
|
||||
<div class="buy-err">
|
||||
<AlertCircle />
|
||||
<span>{catalogError}</span>
|
||||
<button class="btn btn-ghost" type="button" onClick={() => fetchCatalog(token)}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!catalogLoading && !catalogError && items.length === 0 && (
|
||||
<div class="buy-hint">catalog 为空。</div>
|
||||
)}
|
||||
|
||||
<div class="buy-grid">
|
||||
{items.map((it) => (
|
||||
<div class="plan buy-plan" key={it.sku}>
|
||||
<div class="pname">{it.plan}</div>
|
||||
<div class="price">
|
||||
{fmtPrice(it.price_minor, it.currency)}
|
||||
</div>
|
||||
<div class="pdesc">{it.days} 天 · {it.sku}</div>
|
||||
<button
|
||||
class="pcta pcta-fill"
|
||||
type="button"
|
||||
disabled={orderLoading === it.sku}
|
||||
onClick={() => onPickSku(it.sku)}
|
||||
>
|
||||
{orderLoading === it.sku ? '下单中…' : '选择并下单'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{orderError && (
|
||||
<div class="buy-err">
|
||||
<AlertCircle />
|
||||
<span>{orderError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------- step: awaiting payment ---------- */}
|
||||
{step === 'awaiting' && order && (
|
||||
<div class="buy-block">
|
||||
<h2 class="buy-h">3. 假收款 · 等待付款</h2>
|
||||
<p class="buy-hint">
|
||||
订单号 <code>{order.order_no}</code>
|
||||
{payStatus?.pay_status ? <> · pay_status = <code>{payStatus.pay_status}</code></> : null}
|
||||
</p>
|
||||
|
||||
<PaymentPanel session={order.session} />
|
||||
|
||||
<div class="buy-status">
|
||||
{payStatus?.activated ? (
|
||||
<div class="su-ok">
|
||||
<CheckCircle2 />
|
||||
<span>已开通</span>
|
||||
</div>
|
||||
) : (
|
||||
<div class="buy-loading">
|
||||
<Loader2 class="buy-spin" />
|
||||
<span>轮询中(每 2.5s)… activated = {String(payStatus?.activated ?? false)}</span>
|
||||
</div>
|
||||
)}
|
||||
{pollError && (
|
||||
<div class="buy-err">
|
||||
<AlertCircle />
|
||||
<span>轮询出错:{pollError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="buy-actions">
|
||||
<button class="btn btn-ghost buy-mock-btn" type="button" onClick={onMarkPaid} disabled={markPaidLoading}>
|
||||
<FlaskConical />
|
||||
<span>{markPaidLoading ? '标记中…' : '🧪 模拟付款成功(本地联调)'}</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" onClick={onReset}>
|
||||
<RotateCcw />
|
||||
<span>换一个套餐</span>
|
||||
</button>
|
||||
</div>
|
||||
{markPaidError && (
|
||||
<div class="buy-err">
|
||||
<AlertCircle />
|
||||
<span>{markPaidError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------- step: activated ---------- */}
|
||||
{step === 'activated' && (
|
||||
<div class="buy-block buy-block-center">
|
||||
<div class="su-ok buy-ok-big">
|
||||
<CheckCircle2 />
|
||||
<span>已开通!订单 {order?.order_no} 支付完成,套餐已生效。</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg" type="button" onClick={onReset}>
|
||||
<RotateCcw />
|
||||
<span>再下一单</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PaymentPanel — 按 render_type 多态渲染假收款信息。
|
||||
* 三态:crypto_address(地址文本)/ redirect(跳转链接)/ qr(二维码内容文本,
|
||||
* 不引外部 CDN 画码,直接展示可复制的原始内容,严格 CSP 下最稳妥)。
|
||||
*/
|
||||
function PaymentPanel({ session }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
if (!session) return null;
|
||||
|
||||
const { render_type, payload } = session;
|
||||
|
||||
async function copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// 剪贴板权限失败时静默,用户可手动选中复制。
|
||||
}
|
||||
}
|
||||
|
||||
if (render_type === 'redirect') {
|
||||
const url = payload?.url || '';
|
||||
return (
|
||||
<div class="pay-note buy-pay-panel">
|
||||
<ExternalLink />
|
||||
<div>
|
||||
<b>跳转支付</b>
|
||||
<div class="buy-mono">{url}</div>
|
||||
<a class="btn btn-secondary buy-pay-cta" href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink />
|
||||
<span>去支付</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (render_type === 'qr') {
|
||||
const content = payload?.qr_content || '';
|
||||
return (
|
||||
<div class="pay-note buy-pay-panel">
|
||||
<QrCode />
|
||||
<div>
|
||||
<b>二维码内容</b>
|
||||
<div class="buy-hint">未接入本地二维码渲染库,直接展示原始内容,可复制到二维码工具生成。</div>
|
||||
<div class="buy-mono buy-selectable">{content}</div>
|
||||
<button class="btn btn-ghost buy-pay-cta" type="button" onClick={() => copy(content)}>
|
||||
<Copy />
|
||||
<span>{copied ? '已复制' : '复制'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// crypto_address(fake 渠道默认)+ 未识别的 render_type 兜底同样展示。
|
||||
const address = payload?.address || payload?.qr_content || payload?.url || JSON.stringify(payload);
|
||||
const amount = payload?.amount;
|
||||
const currency = payload?.currency;
|
||||
return (
|
||||
<div class="pay-note buy-pay-panel">
|
||||
<QrCode />
|
||||
<div>
|
||||
<b>假收款地址</b>
|
||||
{amount ? (
|
||||
<div class="buy-hint">
|
||||
金额:{amount} {currency || ''}
|
||||
</div>
|
||||
) : null}
|
||||
<div class="buy-mono buy-selectable">{address}</div>
|
||||
<button class="btn btn-ghost buy-pay-cta" type="button" onClick={() => copy(address)}>
|
||||
<Copy />
|
||||
<span>{copied ? '已复制' : '复制地址'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
/**
|
||||
* buy.astro — 本地联调测试路由 /buy:登录 → 拉套餐 → 选档下单 → 假收款 → 模拟付款 → 轮询开通。
|
||||
*
|
||||
* ⚠️ 仅供本地开发联调(对接 server /v1/pay/* + pay dev 端点),不是生产购买页,
|
||||
* 不改首页 / PricingPlans / Pricing.astro,不接入 i18n 字典。
|
||||
* 样式复用 website.css 既有类(section、wrap、plan、pcta、su- 前缀、btn- 前缀),新增小工具类见 buy.css。
|
||||
*/
|
||||
import '@fontsource/sora/500.css';
|
||||
import '@fontsource/sora/600.css';
|
||||
import '@fontsource/sora/700.css';
|
||||
import '@fontsource/manrope/400.css';
|
||||
import '@fontsource/manrope/500.css';
|
||||
import '@fontsource/manrope/600.css';
|
||||
import '@fontsource/manrope/700.css';
|
||||
import '@fontsource/noto-sans-sc/400.css';
|
||||
import '@fontsource/noto-sans-sc/500.css';
|
||||
import '@fontsource/noto-sans-sc/700.css';
|
||||
import '@fontsource/jetbrains-mono/400.css';
|
||||
import '@fontsource/jetbrains-mono/500.css';
|
||||
|
||||
import '../styles/tokens.gen.css';
|
||||
import '../styles/website.css';
|
||||
import '../styles/site-extra.css';
|
||||
import '../styles/buy.css';
|
||||
|
||||
import BuyFlow from '../components/BuyFlow.jsx';
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>本地联调 · 购买流程测试 — Pangolin</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap buy-page">
|
||||
<div class="buy-topbar">
|
||||
<a class="brand" href="/">
|
||||
<span class="nm">Pangolin</span>
|
||||
</a>
|
||||
<span class="buy-badge">🧪 本地联调测试页</span>
|
||||
</div>
|
||||
|
||||
<div class="center">
|
||||
<div class="eyebrow">Dev only</div>
|
||||
<h1 class="h-sec">购买流程本地联调</h1>
|
||||
<p class="sub-sec buy-sub">
|
||||
登录 → 拉取套餐 → 选档下单(method=fake)→ 显示假收款 → 模拟付款成功 → 轮询直到已开通。
|
||||
仅经 Astro dev vite proxy 同源转发到本地 pangolin-server / pay-server。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BuyFlow client:load />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
/* buy.css — /buy 本地联调测试页专属工具类。
|
||||
只加这一页要用、website.css 里没有的样式;颜色/间距一律用既有 --token,禁手写 hex。
|
||||
不改 website.css(保持「逐字迁自原型」不漂移),不影响首页任何路由。 */
|
||||
|
||||
.buy-page { padding: 40px 0 96px; }
|
||||
|
||||
.buy-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 40px; }
|
||||
.buy-topbar .brand .nm { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.01em; }
|
||||
.buy-badge {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.buy-sub { max-width: 640px; }
|
||||
|
||||
.buy-card {
|
||||
max-width: 640px;
|
||||
margin: 36px auto 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.buy-block-center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
|
||||
.buy-h { font-family: var(--font-display); font-weight: 700; font-size: 20px; margin: 0 0 6px; }
|
||||
.buy-hint { font-size: 13.5px; color: var(--fg3); line-height: 1.6; margin: 0 0 16px; }
|
||||
.buy-hint code, .buy-h code { font-family: var(--font-mono); background: var(--bg-subtle); border-radius: var(--radius-sm); padding: 1px 6px; }
|
||||
|
||||
.buy-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-top: 4px; }
|
||||
.buy-plan { padding: 22px 18px; }
|
||||
.buy-plan .price { font-size: 26px; margin: 8px 0 4px; }
|
||||
.buy-plan .pdesc { min-height: auto; margin-bottom: 14px; }
|
||||
|
||||
.buy-loading, .buy-err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.buy-loading { color: var(--fg2); }
|
||||
.buy-loading svg { width: 16px; height: 16px; color: var(--accent); }
|
||||
|
||||
.buy-err { color: var(--danger); background: var(--danger-subtle); border-radius: var(--radius-md); padding: 10px 14px; flex-wrap: wrap; }
|
||||
.buy-err svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.buy-err button { margin-left: auto; }
|
||||
|
||||
.buy-spin { animation: buy-spin 0.9s linear infinite; }
|
||||
@keyframes buy-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.buy-status { margin: 20px 0; }
|
||||
|
||||
.buy-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; }
|
||||
.buy-mock-btn { border: 1.5px dashed var(--border-strong); }
|
||||
|
||||
.buy-pay-panel { margin-top: 0; }
|
||||
.buy-pay-panel > div { width: 100%; }
|
||||
.buy-pay-cta { margin-top: 12px; }
|
||||
|
||||
.buy-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--fg1);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
margin-top: 8px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.buy-selectable { user-select: all; }
|
||||
|
||||
.buy-ok-big { font-size: 15px; }
|
||||
.buy-ok-big svg { width: 22px; height: 22px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.buy-card { padding: 22px; margin-top: 24px; }
|
||||
.buy-topbar { flex-wrap: wrap; gap: 10px; }
|
||||
}
|
||||
Reference in New Issue
Block a user