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
+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