feat(subscription): load vip and credit plans from api

This commit is contained in:
2026-06-26 15:09:27 +08:00
parent abf6d5ae88
commit 49b67064f7
15 changed files with 748 additions and 50 deletions
+113 -43
View File
@@ -17,7 +17,6 @@ import { useRouter } from "next/navigation";
import { Checkbox, MobileShell } from "@/app/_components/core";
import { AppConstants } from "@/core/app_constants";
import { VIP_BENEFITS } from "@/data/constants/vip-benefits";
import { PendingPaymentOrderStorage } from "@/data/storage";
import {
usePaymentDispatch,
@@ -29,18 +28,23 @@ import { ROUTES } from "@/router/routes";
import {
SubscriptionBackLink,
SubscriptionBanner,
SubscriptionBenefitsCard,
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";
@@ -71,6 +75,7 @@ export function SubscriptionScreen({
useState(false);
const [pendingChatReturnAfterVipSync, setPendingChatReturnAfterVipSync] =
useState(false);
const isVipSubscription = subscriptionType === "vip";
useEffect(() => {
if (payment.status === "idle") {
@@ -187,9 +192,25 @@ export function SubscriptionScreen({
.map((plan) => toPlanView(plan, subscriptionType)),
[payment.plans, subscriptionType],
);
const vipOfferPlans = useMemo(
() =>
payment.plans
.filter(isVipPlan)
.slice(0, 3)
.map(toVipOfferPlanView),
[payment.plans],
);
const directCoinsPlans = useMemo(
() => payment.plans.filter(isCreditPlan).map(toCoinsOfferPlanView),
[payment.plans],
);
const selectedPlan =
plans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
(isVipSubscription
? [...vipOfferPlans, ...directCoinsPlans].find(
(plan) => plan.id === payment.selectedPlanId,
)
: plans.find((plan) => plan.id === payment.selectedPlanId)) ?? null;
const isPaymentBusy =
payment.isCreatingOrder ||
payment.isPollingOrder ||
@@ -199,9 +220,6 @@ export function SubscriptionScreen({
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
const autoRenewCaption = payment.autoRenew
? "It will automatically renew upon expiration, and can be canceled at any time"
: "This plan is a one-time purchase and will not automatically renew";
const plansStatusMessage =
subscriptionType === "voice"
? "Voice packages are unavailable."
@@ -260,6 +278,29 @@ export function SubscriptionScreen({
}, [pendingChatReturnAfterVipSync, subscriptionType, user.currentUser?.isVip]);
useEffect(() => {
if (isVipSubscription) {
const firstVipPlanId = vipOfferPlans[0]?.id ?? "";
const hasSelectedVipPlan = vipOfferPlans.some(
(plan) => plan.id === payment.selectedPlanId,
);
const hasSelectedCoinsPlan = directCoinsPlans.some(
(plan) => plan.id === payment.selectedPlanId,
);
const canSelectPlan =
firstVipPlanId.length > 0 &&
(payment.status === "ready" ||
payment.status === "paid" ||
payment.status === "failed");
if (!canSelectPlan || hasSelectedVipPlan || hasSelectedCoinsPlan) return;
paymentDispatch({
type: "PaymentPlanSelected",
planId: firstVipPlanId,
});
return;
}
if (payment.isLoadingPlans || payment.plans.length === 0) return;
if (selectedPlan !== null) return;
@@ -276,8 +317,13 @@ export function SubscriptionScreen({
payment.isLoadingPlans,
payment.plans,
paymentDispatch,
payment.selectedPlanId,
payment.status,
directCoinsPlans,
isVipSubscription,
selectedPlan,
subscriptionType,
vipOfferPlans,
]);
return (
@@ -287,45 +333,69 @@ export function SubscriptionScreen({
<SubscriptionBackLink className={styles.backSlot} />
</header>
<section className={styles.userSlot}>
<SubscriptionUserRow name={name} avatarUrl={avatarUrl} />
</section>
{isVipSubscription ? (
<>
<SubscriptionVipOfferSection
plans={vipOfferPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
/>
<SubscriptionCoinsOfferSection
plans={directCoinsPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
/>
</>
) : (
<>
<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,
})
}
<section className={styles.bannerSlot}>
<SubscriptionBanner
title={SUBSCRIPTION_BANNER_TITLES[subscriptionType]}
/>
))
) : (
<p className={styles.plansStatus}>
{payment.isLoadingPlans
? "Loading membership plans..."
: payment.errorMessage ?? plansStatusMessage}
</p>
)}
</section>
</section>
<p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
<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>
</>
)}
{/* <p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
<section className={styles.benefitsSlot}>
{subscriptionType === "voice" ? (
@@ -336,7 +406,7 @@ export function SubscriptionScreen({
items={VIP_BENEFITS}
/>
)}
</section>
</section> */}
<section className={styles.paymentMethodSlot}>
<SubscriptionPaymentMethod