Files
Jyotisha/frontend/src/hooks/use-billing-panel.ts
T

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,
};
}