331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { FormEvent } from "react";
|
|
import type { Account } from "@/lib/home-types";
|
|
import {
|
|
notifyBalanceChanged,
|
|
redeemErrorMessage,
|
|
type BillingPaneTab,
|
|
type MembershipPlanAlias,
|
|
type MembershipProduct,
|
|
} from "@/lib/membership";
|
|
import { fetchPaymentPackages } from "@/lib/payment-packages-cache";
|
|
|
|
export type BillingAccount = {
|
|
user: { id: string; email: string | null };
|
|
credits: number;
|
|
activeSubscription: Account["activeSubscription"];
|
|
};
|
|
|
|
export type PaymentOrderState = {
|
|
orderNo: string;
|
|
payUrl: string | null;
|
|
qrCode: string | null;
|
|
status: string;
|
|
productId: string;
|
|
};
|
|
|
|
export type PaymentOrder = {
|
|
orderNo: string;
|
|
status: string;
|
|
credits: number;
|
|
moneyCents: number;
|
|
currency: string;
|
|
productName: string;
|
|
paidAt: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type OrdersState = "idle" | "ready" | "unavailable";
|
|
|
|
function toBillingAccount(account: Account | BillingAccount | null): BillingAccount | null {
|
|
if (!account) return null;
|
|
return {
|
|
user: account.user,
|
|
credits: account.credits,
|
|
activeSubscription: account.activeSubscription,
|
|
};
|
|
}
|
|
|
|
export function normalizeOrders(list: readonly unknown[]): PaymentOrder[] {
|
|
return list.map((raw) => {
|
|
const item = (raw ?? {}) as Record<string, unknown>;
|
|
const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record<string, unknown>;
|
|
const price =
|
|
typeof item.price === "number"
|
|
? item.price
|
|
: typeof item.money_cents === "number"
|
|
? item.money_cents
|
|
: typeof item.moneyCents === "number"
|
|
? item.moneyCents
|
|
: 0;
|
|
return {
|
|
orderNo: String(item.order_no ?? item.orderNo ?? ""),
|
|
status: String(item.status ?? "unknown"),
|
|
credits: typeof item.credits === "number" ? item.credits : 0,
|
|
moneyCents: price,
|
|
currency: String(item.currency ?? "CNY"),
|
|
productName:
|
|
typeof snapshot.name === "string"
|
|
? snapshot.name
|
|
: typeof item.name === "string"
|
|
? item.name
|
|
: String(item.product_code ?? item.productCode ?? ""),
|
|
paidAt: item.paid_at ? String(item.paid_at) : item.paidAt ? String(item.paidAt) : null,
|
|
createdAt: String(item.created_at ?? item.createdAt ?? ""),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function useBillingPanel(input: {
|
|
readonly account: Account;
|
|
readonly highlightedPlan: MembershipPlanAlias | null;
|
|
readonly initialTab: BillingPaneTab;
|
|
}) {
|
|
const seedAccount = input.account;
|
|
const [accountError, setAccountError] = useState("");
|
|
const [paymentPackages, setPaymentPackages] = useState<MembershipProduct[]>([]);
|
|
const [paymentEnabled, setPaymentEnabled] = useState(false);
|
|
const [packagesError, setPackagesError] = useState("");
|
|
const [redeemCode, setRedeemCode] = useState("");
|
|
const [redeemError, setRedeemError] = useState("");
|
|
const [redeemMessage, setRedeemMessage] = useState("");
|
|
const [redeeming, setRedeeming] = useState(false);
|
|
const [paymentOrder, setPaymentOrder] = useState<PaymentOrderState | null>(null);
|
|
const [paymentError, setPaymentError] = useState("");
|
|
const [payingProductId, setPayingProductId] = useState<string | null>(null);
|
|
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
|
|
const [redeemDone, setRedeemDone] = useState(false);
|
|
const [orders, setOrders] = useState<PaymentOrder[]>([]);
|
|
const [ordersState, setOrdersState] = useState<OrdersState>("idle");
|
|
const [packagesSettled, setPackagesSettled] = useState(false);
|
|
const highlightedCardRef = useRef<HTMLElement | null>(null);
|
|
const planHighlightScrolled = useRef(false);
|
|
const ordersRequested = useRef(input.initialTab === "orders");
|
|
|
|
const activeSubscription = seedAccount.activeSubscription ?? null;
|
|
const currentProductCode = activeSubscription?.status === "active"
|
|
? activeSubscription.productCode
|
|
: null;
|
|
|
|
const resetPaymentState = useCallback(() => {
|
|
setPaymentEnabled(false);
|
|
setPaymentPackages([]);
|
|
setPaymentOrder(null);
|
|
setPaymentError("");
|
|
setPayingProductId(null);
|
|
setSelectedProductId(null);
|
|
}, []);
|
|
|
|
const fetchPackages = useCallback(async () => {
|
|
try {
|
|
const payload = await fetchPaymentPackages((next) => {
|
|
setPaymentEnabled(next.enabled);
|
|
setPaymentPackages(next.packages);
|
|
setPackagesError("");
|
|
});
|
|
setPaymentEnabled(payload.enabled);
|
|
setPaymentPackages(payload.packages);
|
|
setPackagesError("");
|
|
} catch {
|
|
setPackagesError("套餐支付暂时不可用,请稍后重试");
|
|
resetPaymentState();
|
|
} finally {
|
|
setPackagesSettled(true);
|
|
}
|
|
}, [resetPaymentState]);
|
|
|
|
const fetchOrders = useCallback(async () => {
|
|
ordersRequested.current = true;
|
|
try {
|
|
const response = await fetch("/api/payment/orders", { cache: "no-store" });
|
|
if (response.status === 401) {
|
|
window.location.assign("/login");
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error("订单记录暂时不可用");
|
|
const payload = await response.json().catch(() => null);
|
|
const list = Array.isArray(payload)
|
|
? payload
|
|
: payload && typeof payload === "object" && Array.isArray(payload.orders)
|
|
? payload.orders
|
|
: [];
|
|
setOrders(normalizeOrders(list));
|
|
setOrdersState("ready");
|
|
} catch {
|
|
setOrders([]);
|
|
setOrdersState("unavailable");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void fetchPackages();
|
|
}, [fetchPackages]);
|
|
|
|
useEffect(() => {
|
|
if (!input.highlightedPlan || planHighlightScrolled.current || paymentPackages.length === 0) return;
|
|
planHighlightScrolled.current = true;
|
|
const card = highlightedCardRef.current;
|
|
if (!card) return;
|
|
window.requestAnimationFrame(() => {
|
|
card.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
card.focus({ preventScroll: true });
|
|
});
|
|
}, [input.highlightedPlan, paymentPackages.length]);
|
|
|
|
async function redeem(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
const code = redeemCode.trim();
|
|
if (!code || redeeming) return;
|
|
setRedeeming(true);
|
|
setRedeemError("");
|
|
setRedeemMessage("");
|
|
try {
|
|
const response = await fetch("/api/redeem", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
const payload = await response.json().catch(() => null);
|
|
if (response.status === 401) {
|
|
window.location.assign("/login");
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error(redeemErrorMessage(response.status, payload));
|
|
const result = payload as { awardedCredits?: number; credits: number; message?: string };
|
|
const awarded = typeof result.awardedCredits === "number" ? result.awardedCredits : result.credits;
|
|
setRedeemCode("");
|
|
setRedeemDone(true);
|
|
setRedeemMessage(result.message || `本次到账 ${awarded} 点,最新余额 ${result.credits} 点。`);
|
|
notifyBalanceChanged(result.credits);
|
|
} catch (caught) {
|
|
setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试");
|
|
} finally {
|
|
setRedeeming(false);
|
|
}
|
|
}
|
|
|
|
function closeRedeem() {
|
|
setRedeemDone(false);
|
|
setRedeemMessage("");
|
|
setRedeemError("");
|
|
}
|
|
|
|
async function createPayment(productId: string) {
|
|
if (payingProductId) return;
|
|
setSelectedProductId(productId);
|
|
setPayingProductId(productId);
|
|
setPaymentError("");
|
|
try {
|
|
const response = await fetch("/api/payment/epay/create", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ productId }),
|
|
});
|
|
const payload = await response.json().catch(() => null);
|
|
if (response.status === 401) {
|
|
window.location.assign("/login");
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error(payload?.error || "创建支付失败");
|
|
if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") {
|
|
throw new Error("创建支付失败");
|
|
}
|
|
setPaymentOrder({
|
|
orderNo: payload.orderNo,
|
|
payUrl: payload.payUrl,
|
|
qrCode: payload.qrCode ?? null,
|
|
status: "pending",
|
|
productId,
|
|
});
|
|
window.open(payload.payUrl, "_blank", "noopener,noreferrer");
|
|
} catch (caught) {
|
|
setPaymentError(caught instanceof Error ? caught.message : "创建支付失败");
|
|
} finally {
|
|
setPayingProductId(null);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!paymentOrder || paymentOrder.status !== "pending") return;
|
|
const checkPaymentStatus = async () => {
|
|
const response = await fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" });
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok) return;
|
|
const failed = (typeof payload.status === "string"
|
|
&& ["failed", "closed", "cancelled"].includes(payload.status))
|
|
|| payload.grantStatus === "failed";
|
|
const paid = payload.status === "paid" && !failed;
|
|
setPaymentOrder((current) => current ? {
|
|
...current,
|
|
status: paid ? "paid" : failed ? "failed" : "pending",
|
|
} : current);
|
|
if (paid) {
|
|
if (typeof payload.credits === "number") {
|
|
notifyBalanceChanged(payload.credits);
|
|
}
|
|
}
|
|
};
|
|
let timer = 0;
|
|
const stopPolling = () => {
|
|
if (timer) window.clearInterval(timer);
|
|
timer = 0;
|
|
};
|
|
const startPolling = () => {
|
|
stopPolling();
|
|
timer = window.setInterval(() => void checkPaymentStatus(), 3000);
|
|
};
|
|
const onVisibilityChange = () => {
|
|
if (document.hidden) {
|
|
stopPolling();
|
|
return;
|
|
}
|
|
void checkPaymentStatus();
|
|
startPolling();
|
|
};
|
|
if (!document.hidden) startPolling();
|
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
return () => {
|
|
stopPolling();
|
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
};
|
|
}, [paymentOrder]);
|
|
|
|
return {
|
|
account: toBillingAccount(seedAccount),
|
|
accountError,
|
|
activeSubscription,
|
|
createPayment,
|
|
currentProductCode,
|
|
fetchOrders,
|
|
closeRedeem,
|
|
highlightedCardRef,
|
|
highlightedPlan: input.highlightedPlan,
|
|
initialTab: input.highlightedPlan ? "membership" : input.initialTab,
|
|
orders,
|
|
ordersState,
|
|
packagesError,
|
|
packagesSettled,
|
|
payingProductId,
|
|
paymentEnabled,
|
|
paymentError,
|
|
paymentOrder,
|
|
paymentPackages,
|
|
redeem,
|
|
redeemCode,
|
|
redeemDone,
|
|
redeemError,
|
|
redeeming,
|
|
redeemMessage,
|
|
requestOrders: () => {
|
|
if (!ordersRequested.current) void fetchOrders();
|
|
},
|
|
selectedProductId,
|
|
setRedeemCode,
|
|
setRedeemDone,
|
|
setRedeemError,
|
|
setRedeemMessage,
|
|
};
|
|
}
|