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"; import styles from "./components/sidebar-screen.module.css";
const FALLBACK_USERNAME = "User name"; 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}> <section className={styles.cardSlot}>
<VipBenefitsCard <VipBenefitsCard
state={sidebarState} state={sidebarState}
onActivate={() => router.push(ROUTES.subscription)} onActivate={() => router.push(VIP_SUBSCRIPTION_URL)}
/> />
</section> </section>
@@ -84,7 +86,7 @@ export function SidebarScreen() {
<VoicePackageCard <VoicePackageCard
usedMinutes={0} usedMinutes={0}
totalMinutes={0} totalMinutes={0}
onBuyPackage={() => router.push(ROUTES.subscription)} onBuyPackage={() => router.push(VOICE_SUBSCRIPTION_URL)}
/> />
</section> </section>
@@ -93,11 +95,6 @@ export function SidebarScreen() {
<SettingsSection <SettingsSection
title="Other" title="Other"
items={[ items={[
// {
// id: "membership",
// title: "Membership",
// onClick: () => router.push(ROUTES.subscription),
// },
{ {
id: "logout", id: "logout",
title: "Log out", title: "Log out",
@@ -11,15 +11,19 @@ import styles from "./subscription-banner.module.css";
export interface SubscriptionBannerProps { export interface SubscriptionBannerProps {
className?: string; className?: string;
title?: string;
} }
export function SubscriptionBanner({ className }: SubscriptionBannerProps) { export function SubscriptionBanner({
className,
title = "Subscribe to become the VIP Member",
}: SubscriptionBannerProps) {
return ( return (
<div <div
className={[styles.banner, className].filter(Boolean).join(" ")} className={[styles.banner, className].filter(Boolean).join(" ")}
role="note" 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" /> <span className={styles.pointer} aria-hidden="true" />
</div> </div>
); );
@@ -64,6 +64,8 @@ export function SubscriptionCheckoutButton({
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
? "Creating order..." ? "Creating order..."
: payment.agreed
? "Pay and Activiate"
: "Confirm the agreement and Activate"; : "Confirm the agreement and Activate";
const handleClick = () => { const handleClick = () => {
@@ -7,8 +7,9 @@
padding: var(--spacing-sm) var(--spacing-xs) 0; padding: var(--spacing-sm) var(--spacing-xs) 0;
background: #ffffff; background: #ffffff;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 13px; border-radius: 20px;
border-style: solid; border-style: solid;
box-shadow: 0 5px 7px 0 rgba(247, 89, 168, 0.1);
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
overflow: hidden; overflow: hidden;
@@ -31,11 +32,7 @@
.selected { .selected {
border-color: #f657a0; border-color: #f657a0;
border-width: 3px; border-width: 3px;
box-shadow: 0 3px 5px rgba(247, 89, 168, 0.1); border-radius: 20px;
}
.firstSelected {
padding-top: 24px;
} }
.name { .name {
@@ -81,7 +78,7 @@
align-self: stretch; align-self: stretch;
margin: auto calc(-1 * var(--spacing-xs)) 0; margin: auto calc(-1 * var(--spacing-xs)) 0;
padding: 6px 0; padding: 6px 0;
border-radius: 0 0 13px 13px; border-radius: 0;
color: #ffffff; color: #ffffff;
font-size: var(--font-size-md); font-size: var(--font-size-md);
font-weight: 600; font-weight: 600;
@@ -55,6 +55,10 @@
margin-top: var(--spacing-lg); margin-top: var(--spacing-lg);
} }
.benefitsPlaceholder {
min-height: 160px;
}
.ctaSlot { .ctaSlot {
margin-top: var(--spacing-xl); margin-top: var(--spacing-xl);
} }
@@ -64,6 +68,10 @@
padding: 0 var(--spacing-xs); padding: 0 var(--spacing-xs);
} }
.agreementLabel {
font-size: 17px;
}
.agreementLink { .agreementLink {
color: var(--color-auth-text-primary); color: var(--color-auth-text-primary);
font-weight: 700; font-weight: 700;
+18 -4
View File
@@ -1,7 +1,21 @@
"use client";
import { SubscriptionScreen } from "@/app/subscription/subscription-screen"; import { SubscriptionScreen } from "@/app/subscription/subscription-screen";
export default function SubscriptionPage() { import type { SubscriptionType } from "./subscription-screen";
return <SubscriptionScreen />;
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"; import type { SubscriptionPlanView } from "./components/subscription-plan-card";
const FALLBACK_USERNAME = "User name"; 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 { function isVipPlan(plan: PaymentPlan): boolean {
return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_"); 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 { function currencySymbol(currency: string): string {
if (currency === "CNY") return "¥"; if (currency === "CNY") return "¥";
if (currency === "USD") return "$"; if (currency === "USD") return "$";
@@ -54,24 +81,41 @@ function formatAmount(amountCents: number): string {
return (amountCents / 100).toFixed(2).replace(/\.00$/, ""); 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"; if (plan.vipDays === null) return "Lifetime";
const perDay = plan.amountCents / 100 / Math.max(plan.vipDays, 1); const perDay = plan.amountCents / 100 / Math.max(plan.vipDays, 1);
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`; return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
} }
function toPlanView(plan: PaymentPlan): SubscriptionPlanView { function toPlanView(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): SubscriptionPlanView {
return { return {
id: plan.planId, id: plan.planId,
name: plan.planName, name: plan.planName,
price: formatAmount(plan.amountCents), price: formatAmount(plan.amountCents),
originalPrice: null, originalPrice: null,
perDay: planCaption(plan), perDay: planCaption(plan, subscriptionType),
currencySymbol: currencySymbol(plan.currency), currencySymbol: currencySymbol(plan.currency),
}; };
} }
export function SubscriptionScreen() { export interface SubscriptionScreenProps {
subscriptionType?: SubscriptionType;
}
export function SubscriptionScreen({
subscriptionType = "vip",
}: SubscriptionScreenProps) {
const user = useUserState(); const user = useUserState();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const payment = usePaymentState(); const payment = usePaymentState();
@@ -91,16 +135,16 @@ export function SubscriptionScreen() {
userDispatch({ type: "UserFetch" }); userDispatch({ type: "UserFetch" });
}, [payment.currentOrderId, payment.isPaid, userDispatch]); }, [payment.currentOrderId, payment.isPaid, userDispatch]);
const vipPlans = useMemo( const plans = useMemo(
() => () =>
payment.plans payment.plans
.filter(isVipPlan) .filter((plan) => isPlanForType(plan, subscriptionType))
.map((plan) => toPlanView(plan)), .map((plan) => toPlanView(plan, subscriptionType)),
[payment.plans], [payment.plans, subscriptionType],
); );
const selectedPlan = const selectedPlan =
vipPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null; plans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
const isPaymentBusy = const isPaymentBusy =
payment.isCreatingOrder || payment.isCreatingOrder ||
payment.isPollingOrder || payment.isPollingOrder ||
@@ -108,15 +152,45 @@ export function SubscriptionScreen() {
const canActivate = const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy; selectedPlan !== null && payment.agreed && !isPaymentBusy;
const isVip = const isVip =
user.currentUser?.isVip === true || subscriptionType === "vip" &&
(user.currentUser?.isVip === true ||
payment.vipStatus?.isVip === true || payment.vipStatus?.isVip === true ||
payment.isPaid; payment.isPaid);
const name = user.currentUser?.username ?? FALLBACK_USERNAME; const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.currentUser?.avatarUrl ?? null; const avatarUrl = user.currentUser?.avatarUrl ?? null;
const autoRenewCaption = payment.autoRenew const autoRenewCaption = payment.autoRenew
? "It will automatically renew upon expiration, and can be canceled at any time" ? "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"; : "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 ( return (
<MobileShell> <MobileShell>
@@ -130,15 +204,17 @@ export function SubscriptionScreen() {
</section> </section>
<section className={styles.bannerSlot}> <section className={styles.bannerSlot}>
<SubscriptionBanner /> <SubscriptionBanner
title={SUBSCRIPTION_BANNER_TITLES[subscriptionType]}
/>
</section> </section>
<section <section
className={styles.plansRow} className={styles.plansRow}
aria-label="Choose a subscription plan" aria-label={plansAriaLabel}
> >
{vipPlans.length > 0 ? ( {plans.length > 0 ? (
vipPlans.map((plan, index) => ( plans.map((plan, index) => (
<SubscriptionPlanCard <SubscriptionPlanCard
key={plan.id} key={plan.id}
plan={plan} plan={plan}
@@ -156,7 +232,7 @@ export function SubscriptionScreen() {
<p className={styles.plansStatus}> <p className={styles.plansStatus}>
{payment.isLoadingPlans {payment.isLoadingPlans
? "Loading membership plans..." ? "Loading membership plans..."
: payment.errorMessage ?? "Membership plans are unavailable."} : payment.errorMessage ?? plansStatusMessage}
</p> </p>
)} )}
</section> </section>
@@ -164,10 +240,14 @@ export function SubscriptionScreen() {
<p className={styles.autoRenewCaption}>{autoRenewCaption}</p> <p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
<section className={styles.benefitsSlot}> <section className={styles.benefitsSlot}>
{subscriptionType === "voice" ? (
<div className={styles.benefitsPlaceholder} aria-hidden="true" />
) : (
<SubscriptionBenefitsCard <SubscriptionBenefitsCard
title="VIP Membership Benefits" title="VIP Membership Benefits"
items={VIP_BENEFITS} items={VIP_BENEFITS}
/> />
)}
</section> </section>
<div className={styles.ctaSlot}> <div className={styles.ctaSlot}>
@@ -192,7 +272,7 @@ export function SubscriptionScreen() {
}) })
} }
label={ label={
<> <span className={styles.agreementLabel}>
I agree to the{" "} I agree to the{" "}
<a <a
href={AppConstants.privacyPolicyUrl} href={AppConstants.privacyPolicyUrl}
@@ -211,7 +291,7 @@ export function SubscriptionScreen() {
> >
Automatic renewal Agreement Automatic renewal Agreement
</a> </a>
</> </span>
} }
/> />
</div> </div>