Merge branch 'dev' into test

This commit is contained in:
2026-06-22 10:10:17 +08:00
7 changed files with 146 additions and 44 deletions
+4 -7
View File
@@ -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,6 +64,8 @@ export function SubscriptionCheckoutButton({
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
: payment.agreed
? "Pay and Activiate"
: "Confirm the agreement and Activate";
const handleClick = () => {
@@ -7,8 +7,9 @@
padding: var(--spacing-sm) var(--spacing-xs) 0;
background: #ffffff;
border: 1px solid transparent;
border-radius: 13px;
border-radius: 20px;
border-style: solid;
box-shadow: 0 5px 7px 0 rgba(247, 89, 168, 0.1);
cursor: pointer;
text-align: center;
overflow: hidden;
@@ -31,11 +32,7 @@
.selected {
border-color: #f657a0;
border-width: 3px;
box-shadow: 0 3px 5px rgba(247, 89, 168, 0.1);
}
.firstSelected {
padding-top: 24px;
border-radius: 20px;
}
.name {
@@ -81,7 +78,7 @@
align-self: stretch;
margin: auto calc(-1 * var(--spacing-xs)) 0;
padding: 6px 0;
border-radius: 0 0 13px 13px;
border-radius: 0;
color: #ffffff;
font-size: var(--font-size-md);
font-weight: 600;
@@ -55,6 +55,10 @@
margin-top: var(--spacing-lg);
}
.benefitsPlaceholder {
min-height: 160px;
}
.ctaSlot {
margin-top: var(--spacing-xl);
}
@@ -64,6 +68,10 @@
padding: 0 var(--spacing-xs);
}
.agreementLabel {
font-size: 17px;
}
.agreementLink {
color: var(--color-auth-text-primary);
font-weight: 700;
+18 -4
View File
@@ -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)} />;
}
+98 -18
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 ||
subscriptionType === "vip" &&
(user.currentUser?.isVip === true ||
payment.vipStatus?.isVip === true ||
payment.isPaid;
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}>
{subscriptionType === "voice" ? (
<div className={styles.benefitsPlaceholder} aria-hidden="true" />
) : (
<SubscriptionBenefitsCard
title="VIP Membership Benefits"
items={VIP_BENEFITS}
/>
)}
</section>
<div className={styles.ctaSlot}>
@@ -192,7 +272,7 @@ export function SubscriptionScreen() {
})
}
label={
<>
<span className={styles.agreementLabel}>
I agree to the{" "}
<a
href={AppConstants.privacyPolicyUrl}
@@ -211,7 +291,7 @@ export function SubscriptionScreen() {
>
Automatic renewal Agreement
</a>
</>
</span>
}
/>
</div>