189 lines
7.3 KiB
TypeScript
189 lines
7.3 KiB
TypeScript
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>
|
||
);
|
||
}
|