feat(subscription): split vip and topup flows

This commit is contained in:
2026-06-29 14:45:01 +08:00
parent 72ac923a44
commit 096978e703
17 changed files with 106 additions and 432 deletions
@@ -23,7 +23,7 @@ const log = new Logger("SubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps {
/** 是否可用(未选套餐 / 未勾选协议 → false) */
disabled?: boolean;
subscriptionType: "vip" | "voice";
subscriptionType: "vip" | "topup";
returnTo?: "chat" | null;
}
@@ -97,13 +97,19 @@ export function SubscriptionCheckoutButton({
const isLoading =
payment.isCreatingOrder ||
payment.isPollingOrder;
const readyLabel =
subscriptionType === "topup" ? "Pay and Top Up" : "Pay and Activiate";
const agreementLabel =
subscriptionType === "topup"
? "Confirm the agreement and Top Up"
: "Confirm the agreement and Activate";
const label = payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: payment.agreed
? "Pay and Activiate"
: "Confirm the agreement and Activate";
? readyLabel
: agreementLabel;
const handleClick = () => {
if (disabled || isLoading) return;
@@ -244,7 +250,7 @@ function isEzpayPayment(payParams: Record<string, unknown>): boolean {
interface RedirectToEzpayInput {
orderId: string | null;
paymentUrl: string;
subscriptionType: "vip" | "voice";
subscriptionType: "vip" | "topup";
returnTo?: "chat" | null;
onFailed: (errorMessage: string) => void;
}
@@ -4,7 +4,7 @@ import styles from "./subscription-payment-success-dialog.module.css";
export interface SubscriptionPaymentSuccessDialogProps {
open: boolean;
subscriptionType: "vip" | "voice";
subscriptionType: "vip" | "topup";
onClose: () => void;
}
@@ -16,8 +16,8 @@ export function SubscriptionPaymentSuccessDialog({
if (!open) return null;
const content =
subscriptionType === "voice"
? "Your voice package purchase was completed successfully."
subscriptionType === "topup"
? "Your coins have been added to your wallet successfully."
: "Your VIP membership has been activated successfully.";
return (
+1 -1
View File
@@ -10,7 +10,7 @@ import { ROUTES } from "@/router/routes";
type ReturnStatus = "loading" | "missing";
function buildSubscriptionUrl(
subscriptionType: "vip" | "voice",
subscriptionType: "vip" | "topup",
returnTo?: "chat",
): string {
const params = new URLSearchParams({
@@ -8,7 +8,7 @@ import {
} from "./subscription-screen";
function toSubscriptionType(value: string | null): SubscriptionType {
return value === "voice" ? "voice" : "vip";
return value === "topup" ? "topup" : "vip";
}
function toReturnTo(value: string | null): "chat" | null {
@@ -1,15 +1,9 @@
import type { PaymentPlan } from "@/data/dto/payment";
import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section";
import type { SubscriptionPlanView } from "./components/subscription-plan-card";
import type { VipOfferPlanView } from "./components/subscription-vip-offer-section";
export type SubscriptionType = "vip" | "voice";
export const SUBSCRIPTION_BANNER_TITLES: Record<SubscriptionType, string> = {
vip: "Subscribe to become the VIP Member",
voice: "Buy Voice Message Package",
};
export type SubscriptionType = "vip" | "topup";
export function isVipPlan(plan: PaymentPlan): boolean {
return plan.vipDays !== null;
@@ -19,32 +13,6 @@ export function isCreditPlan(plan: PaymentPlan): boolean {
return plan.dolAmount !== null;
}
function isVoicePackagePlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase();
const planId = plan.planId.toLowerCase();
return (
isCreditPlan(plan) ||
orderType.includes("voice") ||
planId.includes("voice")
);
}
export function isPlanForType(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): boolean {
return subscriptionType === "voice"
? isVoicePackagePlan(plan)
: isVipPlan(plan);
}
function currencySymbol(currency: string): string {
const normalized = currency.toUpperCase();
if (normalized === "CNY") return "¥";
if (normalized === "USD") return "$";
return `${currency} `;
}
function formatAmount(amountCents: number | null): string | null {
if (amountCents === null) return null;
if (!Number.isFinite(amountCents)) return null;
@@ -78,40 +46,6 @@ function vipOfferTitle(plan: PaymentPlan): string {
return plan.planName;
}
function planCaption(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): string {
if (subscriptionType === "voice") {
return plan.dolAmount === null
? "Voice package"
: `${plan.dolAmount} voice messages`;
}
if (plan.dailyPriceCents !== null) {
const dailyPrice = formatAmount(plan.dailyPriceCents);
return dailyPrice === null
? "Lifetime"
: `${currencySymbol(plan.currency)}${dailyPrice}/day`;
}
if (plan.vipDays === null) return "Lifetime";
const fallbackDailyPrice = plan.amountCents / Math.max(plan.vipDays, 1);
return `${currencySymbol(plan.currency)}${formatAmount(fallbackDailyPrice)}/day`;
}
export function toPlanView(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): SubscriptionPlanView {
return {
id: plan.planId,
name: plan.planName,
price: formatAmount(plan.amountCents) ?? "",
originalPrice: formatAmount(plan.originalAmountCents),
perDay: planCaption(plan, subscriptionType),
currencySymbol: currencySymbol(plan.currency),
};
}
export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView {
return {
id: plan.planId,
+24 -94
View File
@@ -9,33 +9,26 @@ import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes";
import {
SubscriptionBackLink,
SubscriptionBanner,
SubscriptionCheckoutButton,
SubscriptionCoinsOfferSection,
SubscriptionPaymentMethod,
SubscriptionPaymentSuccessDialog,
SubscriptionPlanCard,
SubscriptionUserRow,
SubscriptionVipOfferSection,
} from "./components";
import styles from "./components/subscription-screen.module.css";
import {
SUBSCRIPTION_BANNER_TITLES,
isCreditPlan,
isPlanForType,
isVipPlan,
toCoinsOfferPlanView,
toPlanView,
toVipOfferPlanView,
type SubscriptionType,
} from "./subscription-screen.helpers";
const FALLBACK_USERNAME = "User name";
export type { SubscriptionType } from "./subscription-screen.helpers";
export interface SubscriptionScreenProps {
@@ -50,7 +43,6 @@ export function SubscriptionScreen({
returnTo = null,
}: SubscriptionScreenProps) {
const router = useRouter();
const user = useUserState();
const userDispatch = useUserDispatch();
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
@@ -59,7 +51,7 @@ export function SubscriptionScreen({
const successDialogShownOrderRef = useRef<string | null>(null);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
useState(false);
const isVipSubscription = subscriptionType === "vip";
const canSubscribeVip = subscriptionType === "vip";
useEffect(() => {
if (payment.status === "idle") {
@@ -139,14 +131,6 @@ export function SubscriptionScreen({
void PendingPaymentOrderStorage.clearPendingOrder();
}, [payment.currentOrderId, payment.isPaid, payment.status]);
const plans = useMemo(
() =>
payment.plans
.filter((plan) => isPlanForType(plan, subscriptionType))
.slice(0, 3)
.map((plan) => toPlanView(plan, subscriptionType)),
[payment.plans, subscriptionType],
);
const vipOfferPlans = useMemo(
() =>
payment.plans
@@ -161,28 +145,18 @@ export function SubscriptionScreen({
);
const selectedPlan =
(isVipSubscription
(canSubscribeVip
? [...vipOfferPlans, ...directCoinsPlans].find(
(plan) => plan.id === payment.selectedPlanId,
)
: plans.find((plan) => plan.id === payment.selectedPlanId)) ?? null;
: directCoinsPlans.find((plan) => plan.id === payment.selectedPlanId)) ??
null;
const isPaymentBusy =
payment.isCreatingOrder ||
payment.isPollingOrder;
const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy;
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
const plansStatusMessage =
subscriptionType === "voice"
? "Voice packages are unavailable."
: "Membership plans are unavailable.";
const plansAriaLabel =
subscriptionType === "voice"
? "Choose a voice package"
: "Choose a subscription plan";
const finishPaymentSuccessClose = () => {
setShowPaymentSuccessDialog(false);
paymentDispatch({ type: "PaymentReset" });
@@ -196,7 +170,7 @@ export function SubscriptionScreen({
};
useEffect(() => {
if (isVipSubscription) {
if (canSubscribeVip) {
const firstVipPlanId = vipOfferPlans[0]?.id ?? "";
const hasSelectedVipPlan = vipOfferPlans.some(
(plan) => plan.id === payment.selectedPlanId,
@@ -219,28 +193,21 @@ export function SubscriptionScreen({
return;
}
if (payment.isLoadingPlans || payment.plans.length === 0) return;
if (payment.isLoadingPlans || directCoinsPlans.length === 0) return;
if (selectedPlan !== null) return;
const nextPlan = payment.plans.find((plan) =>
isPlanForType(plan, subscriptionType),
);
if (!nextPlan) return;
paymentDispatch({
type: "PaymentPlanSelected",
planId: nextPlan.planId,
planId: directCoinsPlans[0]?.id ?? "",
});
}, [
canSubscribeVip,
directCoinsPlans,
payment.isLoadingPlans,
payment.plans,
paymentDispatch,
payment.selectedPlanId,
payment.status,
directCoinsPlans,
isVipSubscription,
selectedPlan,
subscriptionType,
vipOfferPlans,
]);
@@ -251,8 +218,8 @@ export function SubscriptionScreen({
<SubscriptionBackLink className={styles.backSlot} />
</header>
{isVipSubscription ? (
<div className={styles.offerStack}>
<div className={styles.offerStack}>
{canSubscribeVip ? (
<SubscriptionVipOfferSection
plans={vipOfferPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
@@ -263,55 +230,18 @@ export function SubscriptionScreen({
})
}
/>
<SubscriptionCoinsOfferSection
plans={directCoinsPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
/>
</div>
) : (
<>
<section className={styles.userSlot}>
<SubscriptionUserRow name={name} avatarUrl={avatarUrl} />
</section>
<section className={styles.bannerSlot}>
<SubscriptionBanner
title={SUBSCRIPTION_BANNER_TITLES[subscriptionType]}
/>
</section>
<section className={styles.plansRow} aria-label={plansAriaLabel}>
{plans.length > 0 ? (
plans.map((plan, index) => (
<SubscriptionPlanCard
key={plan.id}
plan={plan}
gradientIndex={index}
selected={payment.selectedPlanId === plan.id}
onClick={() =>
paymentDispatch({
type: "PaymentPlanSelected",
planId: plan.id,
})
}
/>
))
) : (
<p className={styles.plansStatus}>
{payment.isLoadingPlans
? "Loading membership plans..."
: payment.errorMessage ?? plansStatusMessage}
</p>
)}
</section>
</>
)}
) : null}
<SubscriptionCoinsOfferSection
plans={directCoinsPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
/>
</div>
<section className={styles.paymentMethodSlot}>
<SubscriptionPaymentMethod
+1 -2
View File
@@ -85,8 +85,7 @@ export default function SubscriptionSuccessPage() {
🎉 Subscription activated!
</h1>
<p style={{ color: "#666" }}>
Welcome to VIP. Enjoy unlimited chat, free voice minutes, and
more.
Welcome to VIP. Enjoy unlimited chat and more premium benefits.
</p>
<button
type="button"