feat(subscription): support voice package mode

This commit is contained in:
2026-06-22 09:53:35 +08:00
parent cf435e8fbb
commit df7f673855
6 changed files with 136 additions and 35 deletions
+101 -21
View File
@@ -39,11 +39,38 @@ import styles from "./components/subscription-screen.module.css";
import type { SubscriptionPlanView } from "./components/subscription-plan-card";
const FALLBACK_USERNAME = "User name";
export type SubscriptionType = "vip" | "voice";
const SUBSCRIPTION_BANNER_TITLES: Record<SubscriptionType, string> = {
vip: "Subscribe to become the VIP Member",
voice: "Buy Voice Message Package",
};
function isVipPlan(plan: PaymentPlan): boolean {
return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_");
}
function isVoicePackagePlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase();
const planId = plan.planId.toLowerCase();
return (
plan.dolAmount !== null ||
orderType.startsWith("dol_") ||
planId.startsWith("dol_") ||
orderType.includes("voice") ||
planId.includes("voice")
);
}
function isPlanForType(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): boolean {
return subscriptionType === "voice"
? isVoicePackagePlan(plan)
: isVipPlan(plan);
}
function currencySymbol(currency: string): string {
if (currency === "CNY") return "¥";
if (currency === "USD") return "$";
@@ -54,24 +81,41 @@ function formatAmount(amountCents: number): string {
return (amountCents / 100).toFixed(2).replace(/\.00$/, "");
}
function planCaption(plan: PaymentPlan): string {
function planCaption(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): string {
if (subscriptionType === "voice") {
return plan.dolAmount === null
? "Voice package"
: `${plan.dolAmount} voice messages`;
}
if (plan.vipDays === null) return "Lifetime";
const perDay = plan.amountCents / 100 / Math.max(plan.vipDays, 1);
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
}
function toPlanView(plan: PaymentPlan): SubscriptionPlanView {
function toPlanView(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): SubscriptionPlanView {
return {
id: plan.planId,
name: plan.planName,
price: formatAmount(plan.amountCents),
originalPrice: null,
perDay: planCaption(plan),
perDay: planCaption(plan, subscriptionType),
currencySymbol: currencySymbol(plan.currency),
};
}
export function SubscriptionScreen() {
export interface SubscriptionScreenProps {
subscriptionType?: SubscriptionType;
}
export function SubscriptionScreen({
subscriptionType = "vip",
}: SubscriptionScreenProps) {
const user = useUserState();
const userDispatch = useUserDispatch();
const payment = usePaymentState();
@@ -91,16 +135,16 @@ export function SubscriptionScreen() {
userDispatch({ type: "UserFetch" });
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
const vipPlans = useMemo(
const plans = useMemo(
() =>
payment.plans
.filter(isVipPlan)
.map((plan) => toPlanView(plan)),
[payment.plans],
.filter((plan) => isPlanForType(plan, subscriptionType))
.map((plan) => toPlanView(plan, subscriptionType)),
[payment.plans, subscriptionType],
);
const selectedPlan =
vipPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
plans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
const isPaymentBusy =
payment.isCreatingOrder ||
payment.isPollingOrder ||
@@ -108,15 +152,45 @@ export function SubscriptionScreen() {
const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy;
const isVip =
user.currentUser?.isVip === true ||
payment.vipStatus?.isVip === true ||
payment.isPaid;
subscriptionType === "vip" &&
(user.currentUser?.isVip === true ||
payment.vipStatus?.isVip === true ||
payment.isPaid);
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const 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."
: "Membership plans are unavailable.";
const plansAriaLabel =
subscriptionType === "voice"
? "Choose a voice package"
: "Choose a subscription plan";
useEffect(() => {
if (payment.isLoadingPlans || payment.plans.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,
});
}, [
payment.isLoadingPlans,
payment.plans,
paymentDispatch,
selectedPlan,
subscriptionType,
]);
return (
<MobileShell>
@@ -130,15 +204,17 @@ export function SubscriptionScreen() {
</section>
<section className={styles.bannerSlot}>
<SubscriptionBanner />
<SubscriptionBanner
title={SUBSCRIPTION_BANNER_TITLES[subscriptionType]}
/>
</section>
<section
className={styles.plansRow}
aria-label="Choose a subscription plan"
aria-label={plansAriaLabel}
>
{vipPlans.length > 0 ? (
vipPlans.map((plan, index) => (
{plans.length > 0 ? (
plans.map((plan, index) => (
<SubscriptionPlanCard
key={plan.id}
plan={plan}
@@ -156,7 +232,7 @@ export function SubscriptionScreen() {
<p className={styles.plansStatus}>
{payment.isLoadingPlans
? "Loading membership plans..."
: payment.errorMessage ?? "Membership plans are unavailable."}
: payment.errorMessage ?? plansStatusMessage}
</p>
)}
</section>
@@ -164,10 +240,14 @@ export function SubscriptionScreen() {
<p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
<section className={styles.benefitsSlot}>
<SubscriptionBenefitsCard
title="VIP Membership Benefits"
items={VIP_BENEFITS}
/>
{subscriptionType === "voice" ? (
<div className={styles.benefitsPlaceholder} aria-hidden="true" />
) : (
<SubscriptionBenefitsCard
title="VIP Membership Benefits"
items={VIP_BENEFITS}
/>
)}
</section>
<div className={styles.ctaSlot}>