feat(subscription): split vip and topup flows
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user