Files
wtgoku baba209519 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
2026-05-21 16:53:07 +08:00

205 lines
8.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
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">
<section className="section-panel hero-tight">
<div className="section-heading">
<div className="eyebrow">{dictionary.pricing.tag}</div>
<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">
{(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.key}
>
<div className="pricing-top">
<div>
<span className="card-kicker">{plan.badge}</span>
<h2>{plan.name}</h2>
</div>
<div className="price-line">
<strong>{plan.price}</strong>
<span>{plan.cadence}</span>
</div>
</div>
<p className="pricing-summary">{plan.summary}</p>
<ul className="bullet-list">
{plan.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
{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>
<h2>{dictionary.pricing.faqTitle}</h2>
</div>
<div className="faq-grid">
{dictionary.pricing.faqs.map((faq) => (
<article className="faq-card" key={faq.question}>
<h3>{faq.question}</h3>
<p>{faq.answer}</p>
</article>
))}
</div>
</section>
</div>
);
}