From df7f673855fd396af015fc870f4347de7217b152 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 22 Jun 2026 09:53:35 +0800 Subject: [PATCH] feat(subscription): support voice package mode --- src/app/sidebar/sidebar-screen.tsx | 11 +- .../components/subscription-banner.tsx | 8 +- .../subscription-checkout-button.tsx | 4 +- .../components/subscription-screen.module.css | 4 + src/app/subscription/page.tsx | 22 +++- src/app/subscription/subscription-screen.tsx | 122 +++++++++++++++--- 6 files changed, 136 insertions(+), 35 deletions(-) diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx index 719cfef4..3efb25b5 100644 --- a/src/app/sidebar/sidebar-screen.tsx +++ b/src/app/sidebar/sidebar-screen.tsx @@ -19,6 +19,8 @@ import { import styles from "./components/sidebar-screen.module.css"; const FALLBACK_USERNAME = "User name"; +const VIP_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=vip`; +const VOICE_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=voice`; /** * 侧边栏主屏幕 @@ -76,7 +78,7 @@ export function SidebarScreen() {
router.push(ROUTES.subscription)} + onActivate={() => router.push(VIP_SUBSCRIPTION_URL)} />
@@ -84,7 +86,7 @@ export function SidebarScreen() { router.push(ROUTES.subscription)} + onBuyPackage={() => router.push(VOICE_SUBSCRIPTION_URL)} /> @@ -93,11 +95,6 @@ export function SidebarScreen() { router.push(ROUTES.subscription), - // }, { id: "logout", title: "Log out", diff --git a/src/app/subscription/components/subscription-banner.tsx b/src/app/subscription/components/subscription-banner.tsx index d114141e..0531c9d3 100644 --- a/src/app/subscription/components/subscription-banner.tsx +++ b/src/app/subscription/components/subscription-banner.tsx @@ -11,15 +11,19 @@ import styles from "./subscription-banner.module.css"; export interface SubscriptionBannerProps { className?: string; + title?: string; } -export function SubscriptionBanner({ className }: SubscriptionBannerProps) { +export function SubscriptionBanner({ + className, + title = "Subscribe to become the VIP Member", +}: SubscriptionBannerProps) { return (
-

Subscribe to become the VIP Member

+

{title}

); diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 192d5d20..c8c85a29 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -64,7 +64,9 @@ export function SubscriptionCheckoutButton({ ? "Processing payment..." : payment.isCreatingOrder ? "Creating order..." - : "Confirm the agreement and Activate"; + : payment.agreed + ? "Pay and Activiate" + : "Confirm the agreement and Activate"; const handleClick = () => { if (disabled || isLoading) return; diff --git a/src/app/subscription/components/subscription-screen.module.css b/src/app/subscription/components/subscription-screen.module.css index 2271a0ec..9569f34c 100644 --- a/src/app/subscription/components/subscription-screen.module.css +++ b/src/app/subscription/components/subscription-screen.module.css @@ -55,6 +55,10 @@ margin-top: var(--spacing-lg); } +.benefitsPlaceholder { + min-height: 160px; +} + .ctaSlot { margin-top: var(--spacing-xl); } diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx index f7a79541..e9b9625a 100644 --- a/src/app/subscription/page.tsx +++ b/src/app/subscription/page.tsx @@ -1,7 +1,21 @@ -"use client"; - import { SubscriptionScreen } from "@/app/subscription/subscription-screen"; -export default function SubscriptionPage() { - return ; +import type { SubscriptionType } from "./subscription-screen"; + +interface SubscriptionPageProps { + searchParams: Promise<{ + type?: string | string[]; + }>; +} + +function toSubscriptionType(value: string | string[] | undefined): SubscriptionType { + const type = Array.isArray(value) ? value[0] : value; + return type === "voice" ? "voice" : "vip"; +} + +export default async function SubscriptionPage({ + searchParams, +}: SubscriptionPageProps) { + const params = await searchParams; + return ; } diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 5e2e388a..bac4a2e6 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -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 = { + 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 ( @@ -130,15 +204,17 @@ export function SubscriptionScreen() {
- +
- {vipPlans.length > 0 ? ( - vipPlans.map((plan, index) => ( + {plans.length > 0 ? ( + plans.map((plan, index) => ( {payment.isLoadingPlans ? "Loading membership plans..." - : payment.errorMessage ?? "Membership plans are unavailable."} + : payment.errorMessage ?? plansStatusMessage}

)}
@@ -164,10 +240,14 @@ export function SubscriptionScreen() {

{autoRenewCaption}

- + {subscriptionType === "voice" ? ( +