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" ? (
+
+ ) : (
+
+ )}