fix(web): keep the settings dialog one size and move billing into it (BUG-554)

The four account panes now share a fixed frame, chart profiles open as list then detail, and membership lives in the homepage dialog instead of a separate page.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 18:29:51 +08:00
co-authored by Cursor
parent 40bd0e9372
commit dc6598d7e4
48 changed files with 1962 additions and 1411 deletions
+353
View File
@@ -0,0 +1,353 @@
"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";
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 fetchAccountData = useCallback(async (): Promise<BillingAccount | null> => {
try {
const response = await fetch("/api/account", { cache: "no-store" });
if (response.status === 401) {
window.location.assign("/login");
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "暂时无法读取账户信息");
const next = toBillingAccount(payload as Account);
setAccountError("");
return next;
} catch (caught) {
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
return null;
}
}, []);
const resetPaymentState = useCallback(() => {
setPaymentEnabled(false);
setPaymentPackages([]);
setPaymentOrder(null);
setPaymentError("");
setPayingProductId(null);
setSelectedProductId(null);
}, []);
const fetchPackages = useCallback(async () => {
try {
const response = await fetch("/api/payment/packages", { cache: "no-store" });
const payload = await response.json().catch(() => null);
if (response.ok && payload?.enabled === true) {
setPaymentEnabled(true);
setPaymentPackages(payload.packages || []);
setPackagesError("");
return;
}
if (!response.ok) {
setPackagesError("套餐支付暂时不可用,请稍后重试");
resetPaymentState();
}
} 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 (async () => {
await Promise.allSettled([fetchAccountData(), fetchPackages()]);
})();
}, [fetchAccountData, 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} 点。`);
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? 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) {
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? 0);
}
};
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);
};
}, [fetchAccountData, 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,
};
}
+5 -5
View File
@@ -45,7 +45,6 @@ import {
import {
BALANCE_CHANGED_EVENT,
BALANCE_SYNC_KEY,
membershipHref,
} from "@/lib/membership";
import {
CancellationResponseError,
@@ -69,6 +68,7 @@ import type {
ChatSession,
ConsultationStatus,
Message,
OpenAccountDialogOptions,
PendingConsultation,
Profile,
ReplyOutcome,
@@ -138,7 +138,7 @@ export type ConsultationRunParams = {
startNewChat: () => Promise<ChatSession | null>;
continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise<void>;
refreshAccount: () => Promise<void>;
openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void;
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise<void>;
};
@@ -166,7 +166,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
pendingConsultation,
pendingSessionId,
profile,
router,
router: _router,
sessions,
setAccount,
setActiveSessionId,
@@ -555,7 +555,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
: initialConsultationRoute;
if (account.credits <= 0 && !account.activeSubscription) {
router.push(membershipHref("insufficient-credits"));
openAccountDialog("billing", { source: "insufficient-credits" });
return false;
}
@@ -750,7 +750,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
const contentType = response.headers.get("content-type") ?? "";
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
if (response.status === 401) window.location.assign("/login");
if (response.status === 402) router.push(membershipHref("insufficient-credits"));
if (response.status === 402) openAccountDialog("billing", { source: "insufficient-credits" });
throw new ConsultationResponseError(
response.status,
payloadMessage(errorPayload, "服务暂时不可用"),
+18 -6
View File
@@ -1,6 +1,6 @@
"use client";
import type { Dispatch, FormEvent, MutableRefObject, SetStateAction } from "react";
import { type Dispatch, FormEvent, MutableRefObject, SetStateAction } from "react";
import {
beamAvatarSchema,
@@ -40,7 +40,9 @@ import {
type AccountDialog,
type ChartLibraryRecord,
type OnboardingStep,
type OpenAccountDialogOptions,
type Profile,
accountDialogOptions,
} from "@/lib/home-types";
import { preserveShallowEqual } from "@/lib/preserve-shallow-equal";
import {
@@ -74,8 +76,8 @@ export type ProfileOnboardingParams = {
setBirthTimeConsultationConsent: Dispatch<SetStateAction<BirthTimeConsultationConsentState>>;
setBirthTimeError: Dispatch<SetStateAction<string>>;
setBirthTimeJourney: Dispatch<SetStateAction<JourneyClientResponse | null>>;
setBillingPane: Dispatch<SetStateAction<OpenAccountDialogOptions>>;
setDraft: (value: string) => void;
setEditingSelfChart: Dispatch<SetStateAction<boolean>>;
setOnboardingJustCompleted: Dispatch<SetStateAction<boolean>>;
setOnboardingStep: Dispatch<SetStateAction<OnboardingStep>>;
setPresetMessageLength: Dispatch<SetStateAction<number>>;
@@ -116,8 +118,8 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setBirthTimeConsultationConsent,
setBirthTimeError,
setBirthTimeJourney,
setBillingPane,
setDraft,
setEditingSelfChart,
setOnboardingJustCompleted,
setOnboardingStep,
setPresetMessageLength,
@@ -155,8 +157,12 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
}
function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) {
dialogReturnTarget.current = returnTarget ?? accountTrigger.current;
function openAccountDialog(
dialog: AccountDialog,
returnTargetOrOptions?: HTMLButtonElement | null | OpenAccountDialogOptions,
) {
const options = accountDialogOptions(returnTargetOrOptions);
dialogReturnTarget.current = options.returnTarget ?? accountTrigger.current;
setAccountMenuOpen(false);
setAccountError("");
if (dialog === "profile") {
@@ -166,7 +172,13 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
if (dialog === "chart-library") {
setProfileDraft(profile);
setProfileNotice("");
setEditingSelfChart(false);
}
if (dialog === "billing") {
setBillingPane({
source: options.source,
plan: options.plan,
tab: options.tab,
});
}
setActiveAccountDialog(dialog);
}
@@ -15,6 +15,7 @@ import {
type ChatSession,
type Message,
type OnboardingStep,
type OpenAccountDialogOptions,
type Profile,
type Theme,
} from "@/lib/home-types";
@@ -72,7 +73,7 @@ export type RectificationSurfaceParams = {
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void;
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
refreshAccount: () => Promise<void>;
rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise<void>>;
};