lovart3/app/billing/page.tsx
Your Name f874b3464f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add wechat pay channel to unified billing flow
2026-02-12 13:32:13 +08:00

189 lines
7.3 KiB
TypeScript
Raw 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 { redirect } from "next/navigation";
import { CalendarClock, CreditCard, ShieldCheck, Sparkles } from "lucide-react";
import { auth } from "@/app/(auth)/auth";
import { ManageSubscriptionButton } from "@/components/billing/manage-subscription-button";
import {
getCheckoutEnabledPlans,
isWechatPayEnabled,
resolvePlanByMonthlyImageLimit,
} from "@/lib/billing/plans";
import { getBillingTimelineByUser, getUserById } from "@/lib/db/queries";
function formatLimit(limit: number | null) {
if (limit == null || limit === 0) {
return "无限制";
}
return `${limit.toLocaleString("zh-CN")} 张 / 月`;
}
function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function formatEventType(type: string) {
switch (type) {
case "checkout.session.completed":
return "支付完成";
case "customer.subscription.updated":
return "订阅更新";
case "customer.subscription.deleted":
return "订阅取消";
default:
return type;
}
}
function formatChannel(channel: string) {
if (channel === "wechat") {
return "微信";
}
return "Stripe";
}
export default async function BillingPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=/billing");
}
const user = await getUserById({ id: session.user.id });
if (!user) {
redirect("/login?callbackUrl=/billing");
}
const [billingEvents, checkoutEnabledPlans, wechatEnabled] = await Promise.all([
getBillingTimelineByUser({
userId: user.id,
email: user.email,
limit: 12,
}),
Promise.resolve(getCheckoutEnabledPlans()),
Promise.resolve(isWechatPayEnabled()),
]);
const currentPlan = resolvePlanByMonthlyImageLimit(user.monthlyImageLimit);
const checkoutAvailableCount = Object.values(checkoutEnabledPlans).filter(Boolean).length;
return (
<main className="min-h-dvh bg-[#050814] px-4 py-8 text-zinc-100 sm:px-6">
<div className="mx-auto w-full max-w-4xl space-y-6">
<section className="rounded-3xl border border-white/15 bg-white/[0.04] p-6 sm:p-8">
<p className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-zinc-200">
<CreditCard className="h-3.5 w-3.5 text-cyan-300" />
Billing Center
</p>
<h1 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">订阅与账单管理</h1>
<p className="mt-3 text-sm text-zinc-300 sm:text-base">
查看当前套餐、权益额度,并可进入 Stripe 门户管理续费、取消和发票信息。
</p>
</section>
<section className="grid gap-4 md:grid-cols-[1.2fr_1fr]">
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h2 className="text-lg font-semibold text-white">当前订阅</h2>
<div className="mt-4 rounded-xl border border-cyan-300/30 bg-cyan-400/10 p-4">
<p className="text-xs uppercase tracking-wide text-cyan-100">Active Plan</p>
<p className="mt-1 text-2xl font-semibold text-white">{currentPlan.name}</p>
<p className="mt-2 text-sm text-zinc-200">
月度出图额度:<span className="font-medium text-white">{formatLimit(currentPlan.monthlyImageLimit)}</span>
</p>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<Link
href="/lovart/pricing"
className="inline-flex items-center justify-center rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-200"
>
更换套餐
</Link>
{currentPlan.id !== "starter" ? <ManageSubscriptionButton /> : null}
</div>
</article>
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h2 className="text-lg font-semibold text-white">订阅状态</h2>
<div className="mt-3 space-y-2 text-sm text-zinc-300">
<p className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-cyan-300" />
当前账号:{session.user.name || session.user.email}
</p>
<p className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-cyan-300" />
已配置可下单套餐:{checkoutAvailableCount}/3
</p>
<p className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-cyan-300" />
微信支付通道:{wechatEnabled ? "已启用" : "未启用"}
</p>
<p className="text-xs text-zinc-400">
订阅权益按 Webhook 自动写回账户额度(`monthlyImageLimit`)。
</p>
</div>
</article>
</section>
<section className="rounded-2xl border border-white/15 bg-black/30 p-5">
<div className="mb-3 flex items-center gap-2">
<CalendarClock className="h-4 w-4 text-cyan-300" />
<h2 className="text-lg font-semibold text-white">最近订阅事件</h2>
</div>
{billingEvents.length > 0 ? (
<div className="space-y-2">
{billingEvents.map((event) => (
<div
key={event.id}
className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2"
>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-zinc-200">{formatEventType(event.type)}</span>
<span
className={
event.status === "processed" ? "text-emerald-200" : "text-amber-200"
}
>
{event.status === "processed" ? "已处理" : "失败"}
</span>
</div>
<p className="mt-1 text-[11px] text-zinc-400">
{formatChannel(event.channel)} ·{" "}
{formatDateTime(event.occurredAt)}
{event.planId ? ` · ${event.planId}` : ""}
</p>
{event.detail ? <p className="mt-1 text-xs text-zinc-400">{event.detail}</p> : null}
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-300">暂无订阅事件记录。</p>
)}
</section>
<section className="rounded-2xl border border-white/15 bg-black/25 p-5 text-sm text-zinc-300">
<h2 className="text-lg font-semibold text-white">说明</h2>
<ul className="mt-3 space-y-2">
<li>如已完成支付但权益未刷新,可稍后重试或联系管理员检查 Webhook 配置。</li>
<li>取消订阅后,到期会自动回退为 Starter 套餐额度。</li>
<li>账单、付款方式、发票下载统一在 Stripe Portal 中管理。</li>
</ul>
</section>
</div>
</main>
);
}