Expose gateway billing flows from the synced server baseline
The preserved local work adds PopiNewAPI billing checkout, gateway account binding, and matching web console/pricing surfaces on top of the current test-server baseline. During the merge, the start-end video helpers from the deployed baseline were kept alongside the actionGenerate prompt handling from the WIP.
Constraint: Current usable baseline is 7054436, already deployed on the test server.
Rejected: Commit the WIP before syncing the baseline | would have hidden conflicts with the deployed start-end-frame changes.
Confidence: medium
Scope-risk: broad
Directive: Do not deploy this commit to the test server without rechecking gateway credentials and billing checkout behavior in that environment.
Tested: go test ./...
Tested: make build
Tested: cd web && npm run build
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
import Link from "next/link";
|
||||
import { BillingAutoRefresh, BillingRefreshListener } from "@/components/billing-refresh-listener";
|
||||
import { BillingPageState } from "@/components/billing-page-state";
|
||||
import {
|
||||
type BillingCredits,
|
||||
type BillingInvoices,
|
||||
type BillingSubscription,
|
||||
PopiartApiError,
|
||||
getBillingCredits,
|
||||
getBillingInvoices,
|
||||
getBillingSubscription,
|
||||
getViewerSession,
|
||||
} from "@/lib/popiart-api";
|
||||
import { type Locale } from "@/lib/site-content";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatNumber(locale: Locale, value: number) {
|
||||
return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US").format(value);
|
||||
}
|
||||
|
||||
function formatError(error: unknown) {
|
||||
if (error instanceof PopiartApiError) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export default async function BillingPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ kind?: string; status?: string; page?: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const { kind, status, page } = await searchParams;
|
||||
const typedLocale = locale as Locale;
|
||||
const isZh = typedLocale === "zh";
|
||||
const session = await getViewerSession();
|
||||
|
||||
const title = isZh ? "账单中心" : "Billing";
|
||||
const subtitle = isZh
|
||||
? "查看当前订阅、积分钱包和订单历史。"
|
||||
: "Review your active subscription, point wallets, and order history.";
|
||||
const loginCta = isZh ? "去登录" : "Sign in";
|
||||
const consoleCta = isZh ? "前往控制台" : "Open console";
|
||||
const unboundTitle = isZh ? "先绑定网关用户" : "Bind a gateway user first";
|
||||
const unboundBody = isZh
|
||||
? "当前 session 还没有绑定网关用户态,先去控制台绑定后才能读取真实账单与订单。"
|
||||
: "This session is not bound to a gateway user yet. Bind it in the console first to read live billing and order data.";
|
||||
const subscriptionTitle = isZh ? "当前订阅" : "Current subscription";
|
||||
const creditsTitle = isZh ? "积分钱包" : "Point wallets";
|
||||
const invoicesTitle = isZh ? "订单历史" : "Order history";
|
||||
const noSubscription = isZh ? "当前没有有效订阅。" : "No active subscription.";
|
||||
const noCredits = isZh ? "当前没有积分钱包记录。" : "No point wallets.";
|
||||
const noOrders = isZh ? "当前没有订单记录。" : "No orders yet.";
|
||||
const kindFilter = kind === "subscription" || kind === "points" ? kind : "all";
|
||||
const statusFilter = typeof status === "string" && status.trim() ? status.trim() : "all";
|
||||
const currentPage = Number.isFinite(Number(page)) && Number(page) > 0 ? Number(page) : 1;
|
||||
const pageSize = 8;
|
||||
const kindLabel = isZh ? "类型" : "Kind";
|
||||
const statusLabel = isZh ? "状态" : "Status";
|
||||
const allLabel = isZh ? "全部" : "All";
|
||||
const subscriptionsLabel = isZh ? "订阅订单" : "Subscription orders";
|
||||
const pointsLabel = isZh ? "积分包订单" : "Point orders";
|
||||
const prevLabel = isZh ? "上一页" : "Previous";
|
||||
const nextLabel = isZh ? "下一页" : "Next";
|
||||
const pageLabel = isZh ? "页码" : "Page";
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-panel hero-tight">
|
||||
<div className="section-heading">
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-panel console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{isZh ? "登录后查看真实账单" : "Sign in to view live billing"}</h2>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<Link className="button button-dark" href={`/${locale}/login`}>
|
||||
{loginCta}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session.gateway_bound) {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-panel hero-tight">
|
||||
<div className="section-heading">
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-panel console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{unboundTitle}</h2>
|
||||
<p>{unboundBody}</p>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<Link className="button button-dark" href={`/${locale}/console`}>
|
||||
{consoleCta}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let subscription: BillingSubscription | null = null;
|
||||
let credits: BillingCredits | null = null;
|
||||
let invoices: BillingInvoices | null = null;
|
||||
const errors: string[] = [];
|
||||
|
||||
const [subscriptionResult, creditsResult, invoicesResult] = await Promise.allSettled([
|
||||
getBillingSubscription(),
|
||||
getBillingCredits(),
|
||||
getBillingInvoices(),
|
||||
]);
|
||||
|
||||
if (subscriptionResult.status === "fulfilled") {
|
||||
subscription = subscriptionResult.value;
|
||||
} else {
|
||||
errors.push(formatError(subscriptionResult.reason));
|
||||
}
|
||||
if (creditsResult.status === "fulfilled") {
|
||||
credits = creditsResult.value;
|
||||
} else {
|
||||
errors.push(formatError(creditsResult.reason));
|
||||
}
|
||||
if (invoicesResult.status === "fulfilled") {
|
||||
invoices = invoicesResult.value;
|
||||
} else {
|
||||
errors.push(formatError(invoicesResult.reason));
|
||||
}
|
||||
|
||||
type BillingOrderItem = Record<string, unknown> & { __kind: "subscription" | "points" };
|
||||
|
||||
const allOrders: BillingOrderItem[] = [
|
||||
...((invoices?.subscription_orders?.items || []).map((item) => ({ ...item, __kind: "subscription" as const }))),
|
||||
...((invoices?.point_orders?.items || []).map((item) => ({ ...item, __kind: "points" as const }))),
|
||||
];
|
||||
const filteredOrders = allOrders.filter((item) => {
|
||||
if (kindFilter !== "all" && item.__kind !== kindFilter) {
|
||||
return false;
|
||||
}
|
||||
if (statusFilter !== "all" && String(item.status || "") !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(filteredOrders.length / pageSize));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const pagedOrders = filteredOrders.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
const availableStatuses = Array.from(
|
||||
new Set(
|
||||
allOrders
|
||||
.map((item) => String(item.status || "").trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const hasPendingOrders = allOrders.some((item) => {
|
||||
const currentStatus = String(item.status || "").trim();
|
||||
return currentStatus !== "" && !["success", "SUCCESS", "TRADE_SUCCESS", "failed", "FAILED", "closed", "CLOSED", "TRADE_CLOSED"].includes(currentStatus);
|
||||
});
|
||||
|
||||
function buildBillingHref(nextKind: string, nextStatus: string, nextPage: number) {
|
||||
const query = new URLSearchParams();
|
||||
if (nextKind !== "all") {
|
||||
query.set("kind", nextKind);
|
||||
}
|
||||
if (nextStatus !== "all") {
|
||||
query.set("status", nextStatus);
|
||||
}
|
||||
if (nextPage > 1) {
|
||||
query.set("page", String(nextPage));
|
||||
}
|
||||
const serialized = query.toString();
|
||||
return serialized ? `/${locale}/billing?${serialized}` : `/${locale}/billing`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<BillingRefreshListener />
|
||||
<BillingAutoRefresh active={hasPendingOrders} />
|
||||
<BillingPageState />
|
||||
|
||||
<section className="section-panel hero-tight">
|
||||
<div className="section-heading">
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
{errors.length > 0 ? (
|
||||
<div className="status-banner status-banner-error">
|
||||
<span>{errors.join(" | ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="catalog-grid">
|
||||
<article className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{subscriptionTitle}</h2>
|
||||
</div>
|
||||
{subscription && subscription.subscriptions.length > 0 ? (
|
||||
<div className="table-list">
|
||||
{subscription.subscriptions.map((item, index) => (
|
||||
<div className="table-row" key={item.subscription?.id ?? index}>
|
||||
<div>
|
||||
<strong>{item.subscription?.member_level || "subscription"}</strong>
|
||||
<span>{item.subscription?.status || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "可用积分" : "Available points"}</strong>
|
||||
<span>
|
||||
{formatNumber(
|
||||
typedLocale,
|
||||
subscription.subscription_points[String(item.subscription?.id)]?.available_points || 0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{noSubscription}</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{creditsTitle}</h2>
|
||||
</div>
|
||||
{credits && credits.wallets.length > 0 ? (
|
||||
<div className="table-list">
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{isZh ? "当前余额" : "Balance"}</strong>
|
||||
<span>{formatNumber(typedLocale, credits.balance)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "钱包数量" : "Wallet count"}</strong>
|
||||
<span>{formatNumber(typedLocale, credits.wallets.length)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{credits.wallets.map((wallet) => (
|
||||
<div className="table-row" key={wallet.id}>
|
||||
<div>
|
||||
<strong>{wallet.source_type}</strong>
|
||||
<span>{isZh ? "可用积分" : "Available points"}: {formatNumber(typedLocale, wallet.points)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "总积分" : "Total points"}</strong>
|
||||
<span>{formatNumber(typedLocale, wallet.points_total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{noCredits}</p>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{invoicesTitle}</h2>
|
||||
</div>
|
||||
{allOrders.length > 0 ? (
|
||||
<div className="table-list">
|
||||
<div className="billing-filter-row">
|
||||
<div className="billing-filter-group">
|
||||
<strong>{kindLabel}</strong>
|
||||
<div className="billing-filter-links">
|
||||
<Link className={`pill ${kindFilter === "all" ? "pill-active" : ""}`} href={buildBillingHref("all", statusFilter, 1)}>
|
||||
{allLabel}
|
||||
</Link>
|
||||
<Link
|
||||
className={`pill ${kindFilter === "subscription" ? "pill-active" : ""}`}
|
||||
href={buildBillingHref("subscription", statusFilter, 1)}
|
||||
>
|
||||
{subscriptionsLabel}
|
||||
</Link>
|
||||
<Link className={`pill ${kindFilter === "points" ? "pill-active" : ""}`} href={buildBillingHref("points", statusFilter, 1)}>
|
||||
{pointsLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="billing-filter-group">
|
||||
<strong>{statusLabel}</strong>
|
||||
<div className="billing-filter-links">
|
||||
<Link className={`pill ${statusFilter === "all" ? "pill-active" : ""}`} href={buildBillingHref(kindFilter, "all", 1)}>
|
||||
{allLabel}
|
||||
</Link>
|
||||
{availableStatuses.map((itemStatus) => (
|
||||
<Link
|
||||
className={`pill ${statusFilter === itemStatus ? "pill-active" : ""}`}
|
||||
href={buildBillingHref(kindFilter, itemStatus, 1)}
|
||||
key={itemStatus}
|
||||
>
|
||||
{itemStatus}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagedOrders.map((item, index) => (
|
||||
<details className="billing-order-detail" key={`${item.__kind}-${String(item.id ?? index)}`}>
|
||||
<summary className="table-row billing-order-summary">
|
||||
<div>
|
||||
<strong>{String(item.plan_title || item.package_name || item.trade_no || "order")}</strong>
|
||||
<span>{String(item.status || "-")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{String(item.money || "-")} {String(item.currency || "")}</strong>
|
||||
<span>{String(item.payment_method || "-")}</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="billing-order-body">
|
||||
<div className="table-list">
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{isZh ? "交易号" : "Trade no"}</strong>
|
||||
<span>{String(item.trade_no || "-")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "退款状态" : "Refund status"}</strong>
|
||||
<span>{String(item.refund_status || "-")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{isZh ? "完成时间" : "Completed at"}</strong>
|
||||
<span>{String(item.complete_time || "-")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{item.__kind === "subscription" ? (isZh ? "订阅 ID" : "Subscription ID") : (isZh ? "到账积分" : "Delivered points")}</strong>
|
||||
<span>{String(item.__kind === "subscription" ? item.subscription_id : item.points_amount || "-")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<div className="billing-pagination">
|
||||
<Link
|
||||
aria-disabled={safePage <= 1}
|
||||
className={`button button-light button-small ${safePage <= 1 ? "button-disabled" : ""}`}
|
||||
href={buildBillingHref(kindFilter, statusFilter, Math.max(1, safePage - 1))}
|
||||
>
|
||||
{prevLabel}
|
||||
</Link>
|
||||
<span>
|
||||
{pageLabel}: {safePage} / {totalPages}
|
||||
</span>
|
||||
<Link
|
||||
aria-disabled={safePage >= totalPages}
|
||||
className={`button button-light button-small ${safePage >= totalPages ? "button-disabled" : ""}`}
|
||||
href={buildBillingHref(kindFilter, statusFilter, Math.min(totalPages, safePage + 1))}
|
||||
>
|
||||
{nextLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{noOrders}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { type BillingInvoices, getBillingInvoices, getViewerSession } from "@/lib/popiart-api";
|
||||
import { type Locale } from "@/lib/site-content";
|
||||
|
||||
export default async function BillingSuccessPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ kind?: string; provider?: string; trade_no?: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const { kind, provider, trade_no: tradeNo } = await searchParams;
|
||||
const typedLocale = locale as Locale;
|
||||
const isZh = typedLocale === "zh";
|
||||
const session = await getViewerSession();
|
||||
let invoices: BillingInvoices | null = null;
|
||||
|
||||
if (session && tradeNo) {
|
||||
try {
|
||||
invoices = await getBillingInvoices({ keyword: tradeNo, page: 1, pageSize: 10 });
|
||||
} catch {
|
||||
invoices = null;
|
||||
}
|
||||
}
|
||||
|
||||
const matchedOrder =
|
||||
(invoices?.subscription_orders.items || []).find((item) => String(item.trade_no || "") === String(tradeNo || "")) ||
|
||||
(invoices?.point_orders.items || []).find((item) => String(item.trade_no || "") === String(tradeNo || ""));
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-panel hero-tight">
|
||||
<div className="section-heading">
|
||||
<div className="eyebrow">{isZh ? "PAYMENT" : "PAYMENT"}</div>
|
||||
<h1>{isZh ? "支付已完成" : "Payment completed"}</h1>
|
||||
<p>
|
||||
{isZh
|
||||
? "订单状态已切换为成功。你可以继续回到控制台或账单中心查看最新订阅、积分和订单历史。"
|
||||
: "The order has completed successfully. Continue to the console or billing center to review the latest subscription, credits, and order history."}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{isZh ? "支付结果" : "Payment result"}</h2>
|
||||
</div>
|
||||
<div className="table-list">
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{isZh ? "订单类型" : "Order kind"}</strong>
|
||||
<span>{kind || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "支付渠道" : "Provider"}</strong>
|
||||
<span>{provider || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{isZh ? "交易号" : "Trade no"}</strong>
|
||||
<span>{tradeNo || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "状态" : "Status"}</strong>
|
||||
<span>{isZh ? "成功" : "Success"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{matchedOrder ? (
|
||||
<>
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{isZh ? "订单标题" : "Order title"}</strong>
|
||||
<span>{String(matchedOrder.plan_title || matchedOrder.package_name || "-")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "金额" : "Amount"}</strong>
|
||||
<span>{String(matchedOrder.money || "-")} {String(matchedOrder.currency || "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{isZh ? "支付方式" : "Payment method"}</strong>
|
||||
<span>{String(matchedOrder.payment_method || "-")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "完成时间" : "Completed at"}</strong>
|
||||
<span>{String(matchedOrder.complete_time || "-")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<Link className="button button-dark" href={`/${locale}/billing`}>
|
||||
{isZh ? "查看账单中心" : "Open billing"}
|
||||
</Link>
|
||||
<Link className="button button-light" href={`/${locale}/console`}>
|
||||
{isZh ? "前往控制台" : "Open console"}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import { BillingRefreshListener } from "@/components/billing-refresh-listener";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { PopiartApiError, getBudgetSummary, getBudgetUsage, getPopiartEndpoint, getViewerSession } from "@/lib/popiart-api";
|
||||
import { GatewayBindForm } from "@/components/gateway-bind-form";
|
||||
import {
|
||||
type BillingCredits,
|
||||
type BillingInvoices,
|
||||
type BillingSubscription,
|
||||
PopiartApiError,
|
||||
getBillingCredits,
|
||||
getBillingInvoices,
|
||||
getBillingSubscription,
|
||||
getBudgetSummary,
|
||||
getBudgetUsage,
|
||||
getPopiartEndpoint,
|
||||
getProjects,
|
||||
getSkillsCatalog,
|
||||
getViewerSession,
|
||||
} from "@/lib/popiart-api";
|
||||
import { getLiveCopy } from "@/lib/live-copy";
|
||||
import { type Locale } from "@/lib/site-content";
|
||||
|
||||
@@ -30,6 +46,26 @@ function formatError(error: unknown) {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function coerceEpoch(value: unknown) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) && num > 0 ? num : 0;
|
||||
}
|
||||
|
||||
function extractRecentOrder(invoices: BillingInvoices | null) {
|
||||
type BillingOrderItem = Record<string, unknown> & { __kind: "subscription" | "points" };
|
||||
|
||||
const items: BillingOrderItem[] = [
|
||||
...((invoices?.subscription_orders?.items || []).map((item) => ({ ...item, __kind: "subscription" as const }))),
|
||||
...((invoices?.point_orders?.items || []).map((item) => ({ ...item, __kind: "points" as const }))),
|
||||
];
|
||||
items.sort((a, b) => {
|
||||
const aTime = coerceEpoch(a.complete_time) || coerceEpoch(a.create_time) || Number(a.id || 0);
|
||||
const bTime = coerceEpoch(b.complete_time) || coerceEpoch(b.create_time) || Number(b.id || 0);
|
||||
return bTime - aTime;
|
||||
});
|
||||
return items[0] ?? null;
|
||||
}
|
||||
|
||||
export default async function ConsolePage({
|
||||
params,
|
||||
}: {
|
||||
@@ -51,17 +87,64 @@ export default async function ConsolePage({
|
||||
const usedHint = isZh ? "本月累计消耗" : "Consumed this month";
|
||||
const callsLabel = isZh ? "调用次数" : "API calls";
|
||||
const callsHint = isZh ? "本月 CLI / API 调用" : "CLI / API calls this month";
|
||||
const projectsLabel = isZh ? "活跃项目" : "Projects";
|
||||
const projectsHint = isZh ? "当前账号下可见项目" : "Projects visible to this account";
|
||||
const keysTitle = isZh ? "API 密钥" : "API keys";
|
||||
const quickTitle = isZh ? "快速安装指令" : "Quick install commands";
|
||||
const quickBody = isZh
|
||||
? "把下面这段命令复制到终端,完成 CLI 安装、登录和验证。"
|
||||
: "Copy these commands into your terminal to install the CLI, sign in, and verify the workflow.";
|
||||
const usageTitle = isZh ? "近期用量" : "Recent usage";
|
||||
const usageEmpty = isZh ? "当前周期还没有技能用量。" : "No usage has been recorded for the current period.";
|
||||
const skillsTitle = isZh ? "官方技能" : "Official skills";
|
||||
const skillsEmpty = isZh ? "当前账号下没有可见技能。" : "No skills are visible for this account yet.";
|
||||
const projectsTitle = isZh ? "项目" : "Projects";
|
||||
const projectsEmpty = isZh ? "当前账号下没有可见项目。" : "No projects are visible for this account yet.";
|
||||
const billingTitle = isZh ? "网关账单" : "Gateway billing";
|
||||
const billingStatusTitle = isZh ? "订阅状态" : "Subscription";
|
||||
const billingCreditsTitle = isZh ? "积分余额" : "Credits";
|
||||
const recentOrderTitle = isZh ? "最近订单" : "Recent order";
|
||||
const recentOrderEmpty = isZh ? "当前还没有账单订单记录。" : "No billing orders yet.";
|
||||
const billingUnbound = isZh
|
||||
? "当前 session 还没有绑定网关用户态,先绑定后才能读取真实订阅与积分。"
|
||||
: "This session is not bound to a gateway user yet. Bind it first to read live subscription and credit data.";
|
||||
const billingEmpty = isZh ? "当前没有有效订阅。" : "No active subscription was returned.";
|
||||
const walletEmpty = isZh ? "当前没有积分钱包记录。" : "No point wallets were returned.";
|
||||
const boundLabel = isZh ? "已绑定网关用户" : "Gateway user bound";
|
||||
const preferenceLabel = isZh ? "扣费偏好" : "Billing preference";
|
||||
const activeSubscriptionLabel = isZh ? "有效订阅" : "Active subscriptions";
|
||||
const creditBalanceLabel = isZh ? "当前余额" : "Balance";
|
||||
const walletCountLabel = isZh ? "钱包数量" : "Wallets";
|
||||
const availablePointsLabel = isZh ? "可用积分" : "Available points";
|
||||
const totalPointsLabel = isZh ? "总积分" : "Total points";
|
||||
const walletSourceLabel = isZh ? "来源" : "Source";
|
||||
const routeKeyLabel = isZh ? "路由键" : "Route key";
|
||||
const usageJobsLabel = isZh ? "调用次数" : "Jobs";
|
||||
const usageTokensLabel = isZh ? "Tokens" : "Tokens";
|
||||
const usageCostLabel = isZh ? "费用" : "Cost";
|
||||
const sessionKeyLabel = "POPIART_SESSION_KEY";
|
||||
const endpointLabel = "POPIART_ENDPOINT";
|
||||
const copyLabel = isZh ? "复制" : "Copy";
|
||||
const copiedLabel = isZh ? "已复制" : "Copied";
|
||||
const loginCta = isZh ? "去登录" : "Sign in";
|
||||
const docsCta = isZh ? "查看文档" : "Open docs";
|
||||
const bindLabels = {
|
||||
title: isZh ? "绑定网关用户" : "Bind gateway user",
|
||||
body: isZh
|
||||
? "输入网关真实用户 ID 和 user access_token,当前 session 才能读取订阅、积分包和订单。"
|
||||
: "Enter the real gateway user ID and user access token so this session can read subscriptions, point packs, and orders.",
|
||||
userIdLabel: isZh ? "网关用户 ID" : "Gateway user ID",
|
||||
userIdHint: isZh ? "值必须等于网关当前登录用户的真实 ID。" : "This must match the real ID of the gateway user.",
|
||||
tokenLabel: isZh ? "网关 access_token" : "Gateway access token",
|
||||
tokenHint: isZh
|
||||
? "使用网关 `/api/user/token` 生成的普通用户 access_token,不是渠道 key。"
|
||||
: "Use the user access token generated by gateway `/api/user/token`, not a channel key.",
|
||||
submit: isZh ? "绑定网关账单" : "Bind gateway billing",
|
||||
submitting: isZh ? "绑定中..." : "Binding...",
|
||||
invalidUserId: isZh ? "请输入有效的网关用户 ID。" : "Enter a valid gateway user ID.",
|
||||
invalidToken: isZh ? "请输入有效的网关 access_token。" : "Enter a valid gateway access token.",
|
||||
success: isZh ? "绑定成功,正在刷新账单数据。" : "Binding succeeded. Refreshing billing data.",
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
@@ -91,12 +174,47 @@ export default async function ConsolePage({
|
||||
);
|
||||
}
|
||||
|
||||
const [budgetResult, usageResult] = await Promise.allSettled([getBudgetSummary(), getBudgetUsage()]);
|
||||
const [budgetResult, usageResult, skillsResult, projectsResult] = await Promise.allSettled([
|
||||
getBudgetSummary(),
|
||||
getBudgetUsage(),
|
||||
getSkillsCatalog(),
|
||||
getProjects(),
|
||||
]);
|
||||
const budget = budgetResult.status === "fulfilled" ? budgetResult.value : null;
|
||||
const usage = usageResult.status === "fulfilled" ? usageResult.value : null;
|
||||
const loadErrors = [budgetResult, usageResult]
|
||||
const skills = skillsResult.status === "fulfilled" ? skillsResult.value : null;
|
||||
const projects = projectsResult.status === "fulfilled" ? projectsResult.value : null;
|
||||
const loadErrors = [budgetResult, usageResult, skillsResult, projectsResult]
|
||||
.filter((result) => result.status === "rejected")
|
||||
.map((result) => formatError((result as PromiseRejectedResult).reason));
|
||||
let billingSubscription: BillingSubscription | null = null;
|
||||
let billingCredits: BillingCredits | null = null;
|
||||
let billingInvoices: BillingInvoices | null = null;
|
||||
const billingErrors: string[] = [];
|
||||
|
||||
if (session.gateway_bound) {
|
||||
const [subscriptionResult, creditsResult, invoicesResult] = await Promise.allSettled([
|
||||
getBillingSubscription(),
|
||||
getBillingCredits(),
|
||||
getBillingInvoices({ page: 1, pageSize: 1 }),
|
||||
]);
|
||||
if (subscriptionResult.status === "fulfilled") {
|
||||
billingSubscription = subscriptionResult.value;
|
||||
} else {
|
||||
billingErrors.push(formatError(subscriptionResult.reason));
|
||||
}
|
||||
if (creditsResult.status === "fulfilled") {
|
||||
billingCredits = creditsResult.value;
|
||||
} else {
|
||||
billingErrors.push(formatError(creditsResult.reason));
|
||||
}
|
||||
if (invoicesResult.status === "fulfilled") {
|
||||
billingInvoices = invoicesResult.value;
|
||||
} else {
|
||||
billingErrors.push(formatError(invoicesResult.reason));
|
||||
}
|
||||
}
|
||||
const recentOrder = extractRecentOrder(billingInvoices);
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
@@ -114,6 +232,11 @@ export default async function ConsolePage({
|
||||
value: usage ? formatNumber(typedLocale, usage.total.job_count) : "--",
|
||||
hint: callsHint,
|
||||
},
|
||||
{
|
||||
label: projectsLabel,
|
||||
value: projects ? formatNumber(typedLocale, projects.total) : "--",
|
||||
hint: projectsHint,
|
||||
},
|
||||
];
|
||||
|
||||
const quickStart = [
|
||||
@@ -126,6 +249,8 @@ export default async function ConsolePage({
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<BillingRefreshListener />
|
||||
|
||||
<section className="section-panel console-hero">
|
||||
<div className="section-heading">
|
||||
<h1>{pageTitle}</h1>
|
||||
@@ -187,6 +312,137 @@ export default async function ConsolePage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="console-surface-card">
|
||||
{!session.gateway_bound ? (
|
||||
<GatewayBindForm labels={bindLabels} />
|
||||
) : (
|
||||
<>
|
||||
<div className="section-heading compact">
|
||||
<h2>{billingTitle}</h2>
|
||||
<p>
|
||||
{boundLabel}: {session.gateway_user_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<Link className="button button-light button-small" href={`/${locale}/billing`}>
|
||||
{isZh ? "查看账单中心" : "Open billing"}
|
||||
</Link>
|
||||
</div>
|
||||
{billingErrors.length > 0 ? (
|
||||
<div className="status-banner status-banner-error">
|
||||
<span>{billingErrors.join(" | ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="catalog-grid">
|
||||
<article className="console-surface-card billing-nested-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{billingStatusTitle}</h2>
|
||||
</div>
|
||||
{billingSubscription && billingSubscription.subscriptions.length > 0 ? (
|
||||
<div className="table-list">
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{preferenceLabel}</strong>
|
||||
<span>{billingSubscription.billing_preference || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{activeSubscriptionLabel}</strong>
|
||||
<span>{formatNumber(typedLocale, billingSubscription.subscriptions.length)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{billingSubscription.subscriptions.map((item, index) => (
|
||||
<div className="table-row" key={item.subscription?.id ?? index}>
|
||||
<div>
|
||||
<strong>{item.subscription?.member_level || "subscription"}</strong>
|
||||
<span>{item.subscription?.status || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{availablePointsLabel}</strong>
|
||||
<span>
|
||||
{formatNumber(
|
||||
typedLocale,
|
||||
billingSubscription.subscription_points[String(item.subscription?.id)]?.available_points || 0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{billingEmpty}</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="console-surface-card billing-nested-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{billingCreditsTitle}</h2>
|
||||
</div>
|
||||
{billingCredits ? (
|
||||
<div className="table-list">
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{creditBalanceLabel}</strong>
|
||||
<span>{formatNumber(typedLocale, billingCredits.balance)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{walletCountLabel}</strong>
|
||||
<span>{formatNumber(typedLocale, billingCredits.wallets.length)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{billingCredits.wallets.slice(0, 4).map((wallet) => (
|
||||
<div className="table-row" key={wallet.id}>
|
||||
<div>
|
||||
<strong>{walletSourceLabel}: {wallet.source_type}</strong>
|
||||
<span>{availablePointsLabel}: {formatNumber(typedLocale, wallet.points)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{totalPointsLabel}</strong>
|
||||
<span>{formatNumber(typedLocale, wallet.points_total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{walletEmpty}</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="console-surface-card billing-nested-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{recentOrderTitle}</h2>
|
||||
</div>
|
||||
{recentOrder ? (
|
||||
<div className="table-list">
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{String(recentOrder.plan_title || recentOrder.package_name || recentOrder.trade_no || "order")}</strong>
|
||||
<span>{String(recentOrder.status || "-")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{String(recentOrder.money || "-")} {String(recentOrder.currency || "")}</strong>
|
||||
<span>{String(recentOrder.payment_method || "-")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-row">
|
||||
<div>
|
||||
<strong>{isZh ? "交易号" : "Trade no"}</strong>
|
||||
<span>{String(recentOrder.trade_no || "-")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isZh ? "订单类型" : "Order kind"}</strong>
|
||||
<span>{recentOrder.__kind === "subscription" ? (isZh ? "订阅订单" : "Subscription") : (isZh ? "积分包订单" : "Points pack")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{recentOrderEmpty}</p>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{quickTitle}</h2>
|
||||
@@ -198,6 +454,84 @@ export default async function ConsolePage({
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{usageTitle}</h2>
|
||||
</div>
|
||||
{usage && usage.rows.length > 0 ? (
|
||||
<div className="table-list">
|
||||
{usage.rows.map((row) => (
|
||||
<div className="table-row" key={row.dimension}>
|
||||
<div>
|
||||
<strong>{row.dimension}</strong>
|
||||
<span>
|
||||
{usageJobsLabel}: {formatNumber(typedLocale, row.job_count)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
{usageTokensLabel}: {formatNumber(typedLocale, row.tokens_used)}
|
||||
</strong>
|
||||
<span>
|
||||
{usageCostLabel}: ${row.cost_usd.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{usageEmpty}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="catalog-grid">
|
||||
<article className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{skillsTitle}</h2>
|
||||
</div>
|
||||
{skills && skills.items.length > 0 ? (
|
||||
<div className="table-list">
|
||||
{skills.items.slice(0, 6).map((skill) => (
|
||||
<div className="table-row" key={skill.id}>
|
||||
<div>
|
||||
<strong>{skill.name}</strong>
|
||||
<span>{skill.description}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{skill.version}</strong>
|
||||
<span>
|
||||
{routeKeyLabel}: {skill.route_key || skill.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{skillsEmpty}</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{projectsTitle}</h2>
|
||||
</div>
|
||||
{projects && projects.items.length > 0 ? (
|
||||
<div className="table-list">
|
||||
{projects.items.map((project) => (
|
||||
<div className="table-row" key={project.id}>
|
||||
<div>
|
||||
<strong>{project.name}</strong>
|
||||
<span>{project.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="billing-copy">{projectsEmpty}</p>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { BillingPurchaseActions } from "@/components/billing-purchase-actions";
|
||||
import {
|
||||
getBillingCatalog,
|
||||
getBillingPointsPackages,
|
||||
getBillingSubscriptionPlans,
|
||||
getViewerSession,
|
||||
} from "@/lib/popiart-api";
|
||||
import { getDictionary, type Locale } from "@/lib/site-content";
|
||||
|
||||
export default async function PricingPage({
|
||||
@@ -7,7 +14,64 @@ export default async function PricingPage({
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dictionary = getDictionary(locale as Locale);
|
||||
const typedLocale = locale as Locale;
|
||||
const dictionary = getDictionary(typedLocale);
|
||||
const session = await getViewerSession();
|
||||
const isZh = typedLocale === "zh";
|
||||
let plans = dictionary.pricing.plans;
|
||||
let subscriptionPlans: Awaited<ReturnType<typeof getBillingSubscriptionPlans>> = null;
|
||||
let pointsPackages: Awaited<ReturnType<typeof getBillingPointsPackages>> = null;
|
||||
let billingLiveError = "";
|
||||
const bindHint = isZh
|
||||
? "如需查看真实套餐与发起支付,请先在控制台绑定网关用户态。"
|
||||
: "Bind a gateway user in the console first to see live plans and start checkout.";
|
||||
const purchaseLabels = {
|
||||
alipay: isZh ? "支付宝支付" : "Pay with Alipay",
|
||||
wxpay: isZh ? "微信支付" : "Pay with WeChat",
|
||||
creating: isZh ? "创建中..." : "Creating...",
|
||||
tradeNo: isZh ? "交易号" : "Trade no",
|
||||
openLink: isZh ? "打开支付链接" : "Open payment link",
|
||||
codeUrl: isZh ? "扫码地址" : "Code URL",
|
||||
invalid: isZh ? "创建支付失败。" : "Failed to create payment.",
|
||||
pending: isZh ? "支付处理中" : "Payment pending",
|
||||
success: isZh ? "支付成功" : "Payment successful",
|
||||
failed: isZh ? "支付失败" : "Payment failed",
|
||||
paymentStatus: isZh ? "支付状态" : "Payment status",
|
||||
qrTitle: isZh ? "微信扫码支付" : "Scan with WeChat",
|
||||
openConsole: isZh ? "前往控制台" : "Open console",
|
||||
openBilling: isZh ? "查看账单中心" : "Open billing",
|
||||
};
|
||||
|
||||
try {
|
||||
const catalog = await getBillingCatalog();
|
||||
if (catalog?.plans?.length) {
|
||||
plans = catalog.plans.map((plan) => ({
|
||||
badge: plan.badge,
|
||||
name: plan.name,
|
||||
price: plan.price,
|
||||
cadence: plan.cadence,
|
||||
summary: plan.summary,
|
||||
features: plan.features,
|
||||
cta: plan.cta,
|
||||
highlight: plan.highlight,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Keep the seeded dictionary fallback when the product billing catalog is unavailable.
|
||||
}
|
||||
|
||||
if (session?.gateway_bound) {
|
||||
try {
|
||||
const [subscriptionResult, pointsResult] = await Promise.all([
|
||||
getBillingSubscriptionPlans(),
|
||||
getBillingPointsPackages(),
|
||||
]);
|
||||
subscriptionPlans = subscriptionResult;
|
||||
pointsPackages = pointsResult;
|
||||
} catch (error) {
|
||||
billingLiveError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
@@ -17,13 +81,52 @@ export default async function PricingPage({
|
||||
<h1>{dictionary.pricing.title}</h1>
|
||||
<p>{dictionary.pricing.subtitle}</p>
|
||||
</div>
|
||||
{session ? (
|
||||
<div className="status-banner">
|
||||
{session.gateway_bound ? (
|
||||
<span>{isZh ? "已绑定网关用户,可直接读取真实套餐并发起支付。" : "Gateway user bound. Live plans and checkout are available."}</span>
|
||||
) : (
|
||||
<span>{bindHint}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{billingLiveError ? <div className="status-banner status-banner-error">{billingLiveError}</div> : null}
|
||||
</section>
|
||||
|
||||
<section className="pricing-grid">
|
||||
{dictionary.pricing.plans.map((plan) => (
|
||||
{(subscriptionPlans?.items.length ? subscriptionPlans.items.map((item) => ({
|
||||
key: String(item.plan.id),
|
||||
badge: item.plan.recommended ? (isZh ? "推荐方案" : "Recommended") : (isZh ? "订阅方案" : "Subscription"),
|
||||
name: item.plan.title,
|
||||
price: `${item.plan.currency} ${item.plan.price_amount}`,
|
||||
cadence: `${item.plan.duration_value} ${item.plan.duration_unit}`,
|
||||
summary: item.plan.description || item.plan.subtitle,
|
||||
features: [
|
||||
`${item.plan.points_amount} ${isZh ? "订阅赠送积分" : "subscription points"}`,
|
||||
`${item.plan.total_amount || 0} ${isZh ? "总额度" : "total quota"}`,
|
||||
`${isZh ? "会员等级" : "member level"}: ${item.plan.member_level || "-"}`,
|
||||
`${isZh ? "重置周期" : "reset period"}: ${item.plan.quota_reset_period || "-"}`,
|
||||
],
|
||||
cta: isZh ? "购买订阅" : "Buy subscription",
|
||||
highlight: item.plan.recommended,
|
||||
itemId: item.plan.id,
|
||||
kind: "subscription" as const,
|
||||
})) : plans.map((plan) => ({
|
||||
key: plan.name,
|
||||
badge: plan.badge,
|
||||
name: plan.name,
|
||||
price: plan.price,
|
||||
cadence: plan.cadence,
|
||||
summary: plan.summary,
|
||||
features: plan.features,
|
||||
cta: plan.cta,
|
||||
highlight: plan.highlight,
|
||||
itemId: 0,
|
||||
kind: "subscription" as const,
|
||||
}))).map((plan) => (
|
||||
<article
|
||||
className={`pricing-card ${plan.highlight ? "pricing-card-highlight" : ""}`}
|
||||
key={plan.name}
|
||||
key={plan.key}
|
||||
>
|
||||
<div className="pricing-top">
|
||||
<div>
|
||||
@@ -41,13 +144,47 @@ export default async function PricingPage({
|
||||
<li key={feature}>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="button button-dark" href={`/${locale}/login`}>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
{session?.gateway_bound && plan.itemId > 0 ? (
|
||||
<BillingPurchaseActions itemId={plan.itemId} kind={plan.kind} labels={purchaseLabels} />
|
||||
) : (
|
||||
<Link className="button button-dark" href={`/${locale}/login`}>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{pointsPackages?.items.length ? (
|
||||
<section className="pricing-grid">
|
||||
{pointsPackages.items.map((pack) => (
|
||||
<article className="pricing-card" key={pack.id}>
|
||||
<div className="pricing-top">
|
||||
<div>
|
||||
<span className="card-kicker">{isZh ? "积分包" : "Points pack"}</span>
|
||||
<h2>{pack.name}</h2>
|
||||
</div>
|
||||
<div className="price-line">
|
||||
<strong>{pack.currency} {pack.price_amount}</strong>
|
||||
<span>{isZh ? "/包" : "/pack"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="pricing-summary">
|
||||
{isZh
|
||||
? `到账 ${pack.points_amount + pack.bonus_points} 积分(含赠送 ${pack.bonus_points})`
|
||||
: `${pack.points_amount + pack.bonus_points} total points (${pack.bonus_points} bonus)`}
|
||||
</p>
|
||||
<ul className="bullet-list">
|
||||
<li>{isZh ? `基础积分 ${pack.points_amount}` : `Base points ${pack.points_amount}`}</li>
|
||||
<li>{isZh ? `赠送积分 ${pack.bonus_points}` : `Bonus points ${pack.bonus_points}`}</li>
|
||||
<li>{isZh ? "购买成功后直接进入积分钱包" : "Delivered into your point wallets after payment"}</li>
|
||||
</ul>
|
||||
<BillingPurchaseActions itemId={pack.id} kind="points" labels={purchaseLabels} />
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section-panel">
|
||||
<div className="section-heading">
|
||||
<div className="eyebrow">{dictionary.pricing.faqTag}</div>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
type GatewayBindResponse,
|
||||
popiartFetchEnvelope,
|
||||
} from "@/lib/popiart-api";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAUTHENTICATED",
|
||||
message: "missing local popiart session",
|
||||
},
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: { gateway_user_id?: number; gateway_access_token?: string } = {};
|
||||
|
||||
try {
|
||||
body = (await request.json()) as { gateway_user_id?: number; gateway_access_token?: string };
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, payload } = await popiartFetchEnvelope<GatewayBindResponse>("/auth/gateway/bind", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
payload ?? {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
message: "invalid response from popiartServer",
|
||||
},
|
||||
},
|
||||
{ status: response.status },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
message: "failed to reach popiartServer",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
type BillingCheckoutResult,
|
||||
popiartFetchEnvelope,
|
||||
} from "@/lib/popiart-api";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAUTHENTICATED",
|
||||
message: "missing local popiart session",
|
||||
},
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: {
|
||||
kind?: "subscription" | "points";
|
||||
provider?: string;
|
||||
plan_id?: number;
|
||||
package_id?: number;
|
||||
return_url?: string;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
body = (await request.json()) as typeof body;
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const pathname =
|
||||
body.kind === "subscription"
|
||||
? "/billing/checkout/subscription"
|
||||
: body.kind === "points"
|
||||
? "/billing/checkout/points"
|
||||
: "";
|
||||
|
||||
if (!pathname) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "kind must be subscription or points",
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, payload } = await popiartFetchEnvelope<BillingCheckoutResult>(pathname, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
payload ?? {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
message: "invalid response from popiartServer",
|
||||
},
|
||||
},
|
||||
{ status: response.status },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
message: "failed to reach popiartServer",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAUTHENTICATED",
|
||||
message: "missing local popiart session",
|
||||
},
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = new URLSearchParams(url.searchParams);
|
||||
|
||||
try {
|
||||
const { response, payload } = await popiartFetchEnvelope<{
|
||||
message?: string;
|
||||
status?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}>(`/billing/checkout/status?${query.toString()}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
payload ?? {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
message: "invalid response from popiartServer",
|
||||
},
|
||||
},
|
||||
{ status: response.status },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
message: "failed to reach popiartServer",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+120
-1
@@ -1084,6 +1084,113 @@ code {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.billing-purchase-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.billing-purchase-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.billing-purchase-result {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.billing-purchase-result a {
|
||||
color: var(--accent-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.billing-success-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.billing-order-detail {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.billing-order-detail:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.billing-order-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.billing-order-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.billing-order-body {
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.billing-filter-row,
|
||||
.billing-filter-group,
|
||||
.billing-filter-links,
|
||||
.billing-pagination {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.billing-filter-row {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.billing-filter-group {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.billing-filter-links {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.billing-pagination {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.pill-active {
|
||||
background: rgba(44, 107, 255, 0.12);
|
||||
border-color: rgba(44, 107, 255, 0.18);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.button-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.billing-qr-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.billing-qr-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pricing-card h3 {
|
||||
margin: 12px 0 8px;
|
||||
font-family: var(--font-display);
|
||||
@@ -1399,7 +1506,7 @@ code {
|
||||
|
||||
.console-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -1441,6 +1548,18 @@ code {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.gateway-bind-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.billing-nested-card {
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(250, 251, 255, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.console-key-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
|
||||
Reference in New Issue
Block a user