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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, "服务暂时不可用"),
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user