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:
wtgoku
2026-05-21 16:53:07 +08:00
parent 7054436a99
commit baba209519
31 changed files with 6035 additions and 86 deletions
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
const BILLING_STATE_KEY = "popiart-billing-page-state";
export function BillingPageState() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const hasQuery = searchParams.toString().length > 0;
if (hasQuery) {
try {
window.sessionStorage.setItem(BILLING_STATE_KEY, searchParams.toString());
} catch {
// Ignore storage write failures.
}
return;
}
try {
const stored = window.sessionStorage.getItem(BILLING_STATE_KEY);
if (stored) {
router.replace(`${pathname}?${stored}`);
}
} catch {
// Ignore storage read failures.
}
}, [pathname, router, searchParams]);
return null;
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { emitBillingRefreshSignal } from "@/components/billing-refresh-listener";
import { QRCodeSVG } from "@/components/vendor/qrcode-react";
type ApiResponse = {
ok: boolean;
data?: {
message?: string;
trade_no?: string;
url?: string;
code_url?: string;
status?: string;
};
error?: {
message?: string;
};
};
export function BillingPurchaseActions({
kind,
itemId,
labels,
}: {
kind: "subscription" | "points";
itemId: number;
labels: {
alipay: string;
wxpay: string;
creating: string;
tradeNo: string;
openLink: string;
codeUrl: string;
invalid: string;
pending: string;
success: string;
failed: string;
paymentStatus: string;
qrTitle: string;
openConsole: string;
openBilling: string;
};
}) {
const pathname = usePathname();
const router = useRouter();
const [result, setResult] = useState<(ApiResponse["data"] & { provider?: string; kind?: string }) | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string>("");
const [pendingProvider, setPendingProvider] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const locale = useMemo(() => pathname.split("/")[1] || "zh", [pathname]);
function checkout(provider: "alipay" | "wxpay") {
startTransition(async () => {
setPendingProvider(provider);
setError(null);
setResult(null);
setStatus("");
try {
const response = await fetch("/api/billing/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
kind,
provider,
plan_id: kind === "subscription" ? itemId : undefined,
package_id: kind === "points" ? itemId : undefined,
return_url: typeof window !== "undefined" ? `${window.location.origin}${pathname}` : "",
}),
});
const payload = (await response.json()) as ApiResponse;
if (!response.ok || !payload.ok) {
setError(payload.error?.message ?? labels.invalid);
return;
}
setResult({ ...(payload.data ?? null), provider, kind });
setStatus(labels.pending);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : labels.invalid);
} finally {
setPendingProvider(null);
}
});
}
useEffect(() => {
if (!result?.trade_no || !result.provider || !result.kind) {
return;
}
const currentResult = result;
const currentKind = currentResult.kind ?? "";
const currentProvider = currentResult.provider ?? "";
if (!currentKind || !currentProvider) {
return;
}
let cancelled = false;
let attempts = 0;
async function poll() {
attempts += 1;
try {
const query = new URLSearchParams();
query.set("kind", currentKind);
query.set("provider", currentProvider);
query.set("trade_no", currentResult.trade_no ?? "");
const response = await fetch(`/api/billing/checkout?${query.toString()}`);
const payload = (await response.json()) as ApiResponse;
if (!response.ok || !payload.ok) {
if (!cancelled) {
setError(payload.error?.message ?? labels.invalid);
}
return;
}
const nextStatus = payload.data?.status ?? "";
if (!cancelled && nextStatus) {
setStatus(nextStatus);
}
if (cancelled) {
return;
}
if (nextStatus === "SUCCESS" || nextStatus === "TRADE_SUCCESS") {
setStatus(labels.success);
emitBillingRefreshSignal();
window.setTimeout(() => {
router.push(
`/${locale}/billing/success?kind=${encodeURIComponent(currentKind)}&provider=${encodeURIComponent(currentProvider)}&trade_no=${encodeURIComponent(currentResult.trade_no ?? "")}`,
);
}, 1200);
return;
}
if (nextStatus === "FAILED" || nextStatus === "CLOSED" || nextStatus === "TRADE_CLOSED") {
setStatus(labels.failed);
return;
}
if (attempts < 20) {
window.setTimeout(poll, 3000);
}
} catch (requestError) {
if (!cancelled) {
setError(requestError instanceof Error ? requestError.message : labels.invalid);
}
}
}
const timer = window.setTimeout(poll, 2000);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [labels.failed, labels.invalid, labels.success, locale, result, router]);
return (
<div className="billing-purchase-stack">
<div className="billing-purchase-actions">
<button className="button button-dark" disabled={isPending} onClick={() => checkout("alipay")} type="button">
{isPending && pendingProvider === "alipay" ? labels.creating : labels.alipay}
</button>
<button className="button button-light" disabled={isPending} onClick={() => checkout("wxpay")} type="button">
{isPending && pendingProvider === "wxpay" ? labels.creating : labels.wxpay}
</button>
</div>
{error ? <div className="status-banner status-banner-error">{error}</div> : null}
{result ? (
<div className="status-banner billing-purchase-result">
{result.trade_no ? (
<span>
{labels.tradeNo}: {result.trade_no}
</span>
) : null}
{status ? (
<span>
{labels.paymentStatus}: {status}
</span>
) : null}
{result.url ? (
<a href={result.url} rel="noreferrer" target="_blank">
{labels.openLink}
</a>
) : null}
{result.code_url ? <span>{labels.codeUrl}: {result.code_url}</span> : null}
{result.code_url ? (
<div className="billing-qr-block">
<strong>{labels.qrTitle}</strong>
<div className="billing-qr-image">
<QRCodeSVG level="M" size={160} value={result.code_url} />
</div>
</div>
) : null}
{status === labels.success ? (
<div className="billing-success-links">
<Link className="button button-light button-small" href={`/${locale}/console`}>
{labels.openConsole}
</Link>
<Link className="button button-light button-small" href={`/${locale}/billing`}>
{labels.openBilling}
</Link>
</div>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
const BILLING_REFRESH_KEY = "popiart-billing-refresh";
const BILLING_REFRESH_EVENT = "popiart:billing-refresh";
export function emitBillingRefreshSignal() {
const value = String(Date.now());
try {
window.localStorage.setItem(BILLING_REFRESH_KEY, value);
} catch {
// Ignore storage failures and still dispatch the in-page event.
}
window.dispatchEvent(new CustomEvent(BILLING_REFRESH_EVENT, { detail: value }));
}
export function BillingRefreshListener() {
const router = useRouter();
useEffect(() => {
function handleStorage(event: StorageEvent) {
if (event.key === BILLING_REFRESH_KEY && event.newValue) {
router.refresh();
}
}
function handleCustomEvent() {
router.refresh();
}
window.addEventListener("storage", handleStorage);
window.addEventListener(BILLING_REFRESH_EVENT, handleCustomEvent);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(BILLING_REFRESH_EVENT, handleCustomEvent);
};
}, [router]);
return null;
}
export function BillingAutoRefresh({
active,
intervalMs = 5000,
maxRefreshes = 12,
}: {
active: boolean;
intervalMs?: number;
maxRefreshes?: number;
}) {
const router = useRouter();
useEffect(() => {
if (!active) {
return;
}
let count = 0;
const timer = window.setInterval(() => {
count += 1;
router.refresh();
if (count >= maxRefreshes) {
window.clearInterval(timer);
}
}, intervalMs);
return () => {
window.clearInterval(timer);
};
}, [active, intervalMs, maxRefreshes, router]);
return null;
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
type ApiResponse = {
ok: boolean;
error?: {
message?: string;
};
};
export function GatewayBindForm({
labels,
}: {
labels: {
title: string;
body: string;
userIdLabel: string;
userIdHint: string;
tokenLabel: string;
tokenHint: string;
submit: string;
submitting: string;
invalidUserId: string;
invalidToken: string;
success: string;
};
}) {
const router = useRouter();
const [gatewayUserId, setGatewayUserId] = useState("");
const [gatewayAccessToken, setGatewayAccessToken] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const userId = Number(gatewayUserId.trim());
const accessToken = gatewayAccessToken.trim();
if (!Number.isInteger(userId) || userId <= 0) {
setError(labels.invalidUserId);
setSuccess(null);
return;
}
if (!accessToken) {
setError(labels.invalidToken);
setSuccess(null);
return;
}
startTransition(async () => {
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/auth/gateway-bind", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
gateway_user_id: userId,
gateway_access_token: accessToken,
}),
});
const payload = (await response.json()) as ApiResponse;
if (!response.ok || !payload.ok) {
setError(payload.error?.message ?? labels.invalidToken);
return;
}
setSuccess(labels.success);
router.refresh();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : labels.invalidToken);
}
});
}
return (
<div className="gateway-bind-stack">
<div className="section-heading compact">
<h2>{labels.title}</h2>
<p>{labels.body}</p>
</div>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="field-label" htmlFor="gateway-user-id">
{labels.userIdLabel}
</label>
<input
className="text-input"
id="gateway-user-id"
inputMode="numeric"
onChange={(event) => setGatewayUserId(event.target.value)}
placeholder="123"
type="text"
value={gatewayUserId}
/>
<p className="field-hint">{labels.userIdHint}</p>
<label className="field-label" htmlFor="gateway-access-token">
{labels.tokenLabel}
</label>
<input
className="text-input"
id="gateway-access-token"
onChange={(event) => setGatewayAccessToken(event.target.value)}
placeholder="access_token"
spellCheck={false}
type="password"
value={gatewayAccessToken}
/>
<p className="field-hint">{labels.tokenHint}</p>
{error ? <div className="status-banner status-banner-error">{error}</div> : null}
{success ? <div className="status-banner">{success}</div> : null}
<button className="button button-dark" disabled={isPending} type="submit">
{isPending ? labels.submitting : labels.submit}
</button>
</form>
</div>
);
}
+4
View File
@@ -29,6 +29,7 @@ export function MainNav({
skills: string;
console: string;
pricing: string;
billing?: string;
};
}) {
const pathname = usePathname();
@@ -39,6 +40,9 @@ export function MainNav({
{ href: localize(locale, "/console"), label: labels.console },
{ href: localize(locale, "/pricing"), label: labels.pricing },
];
if (labels.billing) {
items.push({ href: localize(locale, "/billing"), label: labels.billing });
}
return (
<nav className="main-nav" aria-label="Primary">
+2
View File
@@ -44,6 +44,7 @@ export async function SiteChrome({
skills: liveCopy.nav.skills,
console: dictionary.nav.console,
pricing: dictionary.nav.pricing,
billing: locale === "zh" ? "账单" : "Billing",
}}
locale={locale}
/>
@@ -74,6 +75,7 @@ export async function SiteChrome({
<Link href={localize(locale, "/docs")}>{dictionary.nav.docs}</Link>
<Link href={localize(locale, "/skills")}>{liveCopy.nav.skills}</Link>
<Link href={localize(locale, "/pricing")}>{dictionary.nav.pricing}</Link>
<Link href={localize(locale, "/billing")}>{locale === "zh" ? "账单" : "Billing"}</Link>
<Link href={localize(locale, "/console")}>{dictionary.nav.console}</Link>
</div>
</footer>
+11
View File
@@ -0,0 +1,11 @@
declare module "@/components/vendor/qrcode-react" {
import * as React from "react";
export interface QRCodeSVGProps extends React.SVGProps<SVGSVGElement> {
value: string;
size?: number;
level?: "L" | "M" | "Q" | "H";
}
export const QRCodeSVG: React.ComponentType<QRCodeSVGProps>;
}
File diff suppressed because it is too large Load Diff