feat(subscription): support voice package mode
This commit is contained in:
@@ -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() {
|
||||
<section className={styles.cardSlot}>
|
||||
<VipBenefitsCard
|
||||
state={sidebarState}
|
||||
onActivate={() => router.push(ROUTES.subscription)}
|
||||
onActivate={() => router.push(VIP_SUBSCRIPTION_URL)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -84,7 +86,7 @@ export function SidebarScreen() {
|
||||
<VoicePackageCard
|
||||
usedMinutes={0}
|
||||
totalMinutes={0}
|
||||
onBuyPackage={() => router.push(ROUTES.subscription)}
|
||||
onBuyPackage={() => router.push(VOICE_SUBSCRIPTION_URL)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -93,11 +95,6 @@ export function SidebarScreen() {
|
||||
<SettingsSection
|
||||
title="Other"
|
||||
items={[
|
||||
// {
|
||||
// id: "membership",
|
||||
// title: "Membership",
|
||||
// onClick: () => router.push(ROUTES.subscription),
|
||||
// },
|
||||
{
|
||||
id: "logout",
|
||||
title: "Log out",
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={[styles.banner, className].filter(Boolean).join(" ")}
|
||||
role="note"
|
||||
>
|
||||
<p className={styles.text}>Subscribe to become the VIP Member</p>
|
||||
<p className={styles.text}>{title}</p>
|
||||
<span className={styles.pointer} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.benefitsPlaceholder {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.ctaSlot {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { SubscriptionScreen } from "@/app/subscription/subscription-screen";
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
return <SubscriptionScreen />;
|
||||
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 <SubscriptionScreen subscriptionType={toSubscriptionType(params.type)} />;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user