feat(subscription): split vip and topup flows

This commit is contained in:
2026-06-29 14:45:01 +08:00
parent 72ac923a44
commit 096978e703
17 changed files with 106 additions and 432 deletions
@@ -12,7 +12,7 @@
* *
* 业务关系: * 业务关系:
* - 与 `src/app/sidebar/components/vip-benefits-card.tsx` 的 "Activate VIP Membership" 行为一致 * - 与 `src/app/sidebar/components/vip-benefits-card.tsx` 的 "Activate VIP Membership" 行为一致
* - 与 `src/app/sidebar/components/voice-package-card.tsx` 的 "Buy Package" 行为一致 * - 与订阅页 VIP 入口行为一致
* *
* 不做的事: * 不做的事:
* - 不直接管理 state machinechat 机器不感知 UI 层) * - 不直接管理 state machinechat 机器不感知 UI 层)
-1
View File
@@ -6,4 +6,3 @@ export * from "./back-bar";
export * from "./sidebar-wallet-card"; export * from "./sidebar-wallet-card";
export * from "./user-header"; export * from "./user-header";
export * from "./vip-benefits-card"; export * from "./vip-benefits-card";
export * from "./voice-package-card";
@@ -98,26 +98,52 @@
transform: translateY(1px); transform: translateY(1px);
} }
.actionColumn {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 9px;
align-items: stretch;
}
.activateButton,
.topUpButton { .topUpButton {
display: inline-flex; display: inline-flex;
min-width: 92px; min-width: 92px;
min-height: 48px; min-height: 44px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 18px; padding: 0 18px;
border: 0; border: 1px solid transparent;
border-radius: 18px; border-radius: 18px;
cursor: pointer;
font: inherit;
font-size: 15px;
font-weight: 850;
letter-spacing: 0.1px;
transition: box-shadow 0.18s ease, opacity 0.18s ease, transform 0.18s ease,
background 0.18s ease;
}
.activateButton {
background: #ffffff;
color: #f657a0;
border-color: rgba(246, 87, 160, 0.28);
box-shadow: 0 10px 22px rgba(246, 87, 160, 0.12);
}
.topUpButton {
background: background:
linear-gradient(135deg, #f657a0 0%, #ff7ac3 100%), linear-gradient(135deg, #f657a0 0%, #ff7ac3 100%),
#f657a0; #f657a0;
color: #ffffff; color: #ffffff;
cursor: pointer;
font: inherit;
font-size: 16px;
font-weight: 850;
letter-spacing: 0.1px;
box-shadow: 0 12px 26px rgba(246, 87, 160, 0.28); box-shadow: 0 12px 26px rgba(246, 87, 160, 0.28);
transition: box-shadow 0.18s ease, opacity 0.18s ease, transform 0.18s ease; }
.activateButton:hover {
background: #fff5fa;
box-shadow: 0 14px 28px rgba(246, 87, 160, 0.18);
transform: translateY(-1px);
} }
.topUpButton:hover { .topUpButton:hover {
@@ -125,12 +151,14 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.activateButton:active,
.topUpButton:active { .topUpButton:active {
opacity: 0.92; opacity: 0.92;
transform: translateY(1px); transform: translateY(1px);
} }
.rulesButton:focus-visible, .rulesButton:focus-visible,
.activateButton:focus-visible,
.topUpButton:focus-visible { .topUpButton:focus-visible {
outline: 2px solid #f657a0; outline: 2px solid #f657a0;
outline-offset: 3px; outline-offset: 3px;
@@ -142,11 +170,12 @@
padding: 16px; padding: 16px;
} }
.activateButton,
.topUpButton { .topUpButton {
min-width: 78px; min-width: 78px;
min-height: 44px; min-height: 40px;
padding: 0 14px; padding: 0 14px;
font-size: 15px; font-size: 14px;
} }
.balanceValue { .balanceValue {
@@ -6,6 +6,7 @@ import styles from "./sidebar-wallet-card.module.css";
export interface SidebarWalletCardProps { export interface SidebarWalletCardProps {
creditBalance: number; creditBalance: number;
onActivateVip?: () => void;
onTopUp: () => void; onTopUp: () => void;
onRulesClick: () => void; onRulesClick: () => void;
} }
@@ -15,6 +16,7 @@ const formatCoins = (value: number): string =>
export function SidebarWalletCard({ export function SidebarWalletCard({
creditBalance, creditBalance,
onActivateVip,
onTopUp, onTopUp,
onRulesClick, onRulesClick,
}: SidebarWalletCardProps) { }: SidebarWalletCardProps) {
@@ -42,9 +44,20 @@ export function SidebarWalletCard({
</button> </button>
</div> </div>
<div className={styles.actionColumn}>
{onActivateVip ? (
<button
type="button"
className={styles.activateButton}
onClick={onActivateVip}
>
Activate VIP
</button>
) : null}
<button type="button" className={styles.topUpButton} onClick={onTopUp}> <button type="button" className={styles.topUpButton} onClick={onTopUp}>
Top Up Top Up
</button> </button>
</div>
</section> </section>
); );
} }
@@ -1,136 +0,0 @@
.card {
background-image:
linear-gradient(
180deg,
rgba(250, 208, 198, 0.57) 0%,
rgba(250, 176, 165, 0.57) 0%,
rgba(250, 143, 132, 0.57) 0%,
rgba(251, 176, 156, 0.57) 8%,
rgba(251, 208, 180, 0.57) 16%,
rgba(255, 255, 255, 0.57) 38%,
rgba(255, 255, 255, 0.57) 100%
),
linear-gradient(#ffffff, #ffffff);
background-blend-mode: normal, normal;
border: solid 1px #fbbcb6;
border-radius: 21px;
width: 100%;
padding: var(--sidebar-card-padding-y, 18px)
var(--sidebar-card-padding-right, 12px)
var(--sidebar-card-padding-y, 18px)
var(--sidebar-card-padding-x, 18px);
box-sizing: border-box;
overflow: hidden;
}
.content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md, 12px);
}
.copy {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-sm, 8px);
}
.header {
padding: 0;
}
.title {
font-size: var(--font-responsive-xl, 20px);
font-weight: 700;
color: var(--color-accent, #f84d96);
}
.micIcon {
flex: 0 0 auto;
width: var(--sidebar-voice-image-size, 100px);
height: var(--sidebar-voice-image-size, 100px);
display: flex;
align-items: center;
justify-content: center;
}
.micImage {
display: block;
width: var(--sidebar-voice-image-size, 100px);
height: var(--sidebar-voice-image-size, 100px);
object-fit: contain;
}
.usage {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
margin: 0;
padding: 0;
font-size: var(--font-size-md, 14px);
color: var(--color-text-secondary, #9e9e9e);
line-height: 1.4;
}
.usedNumber {
display: inline-flex;
color: #fa438b;
font-size: var(--font-responsive-number-lg, 30px);
font-weight: 700;
line-height: 1;
}
.totalNumber {
display: inline-flex;
color: #000000;
font-size: var(--font-responsive-number-lg, 30px);
font-weight: 700;
line-height: 1;
}
.usageUnit {
color: var(--color-text-secondary, #9e9e9e);
display: inline-flex;
font: 400 var(--font-responsive-lg, 18px) / 1 var(--font-sans);
}
.usageText {
color: #adabab;
font-size: var(--font-responsive-lg, 18px);
font-weight: 400;
}
.footer {
padding: 0;
}
.buyBtn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xs) var(--spacing-lg, 16px);
border-radius: 22px;
background-image:
linear-gradient(91deg, #f67382 0%, #f66690 44%, #f657a0 100%),
linear-gradient(#f69757, #f69757);
background-blend-mode: normal, normal;
color: var(--color-text-primary, #ffffff);
font-size: var(--font-responsive-md, 16px);
font-weight: 600;
border: 0;
cursor: pointer;
box-sizing: border-box;
}
.buyBtn:hover {
opacity: 0.9;
}
.buyBtn:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 2px;
}
@@ -1,70 +0,0 @@
"use client";
import Image from "next/image";
import styles from "./voice-package-card.module.css";
export interface VoicePackageCardProps {
/** 已使用分钟数 */
usedMinutes: number;
/** 总分钟数 */
totalMinutes: number;
/** "Buy Package" 按钮点击 */
onBuyPackage: () => void;
}
/**
* Voice Message Package 卡片
*
* 视觉规格(与设计稿对齐):
* - 白色卡片 + 粉色→橙色渐变描边
* - 标题 "Voice Message Package"(粉色加粗)
* - "0min/0min used/remaining" 文案(前缀粉色加粗)
* - 底部 "Buy Package" 粉色胶囊按钮
*/
export function VoicePackageCard({
usedMinutes,
totalMinutes,
onBuyPackage,
}: VoicePackageCardProps) {
return (
<section className={styles.card}>
<div className={styles.content}>
<div className={styles.copy}>
<header className={styles.header}>
<h2 className={styles.title}>Voice Message Package</h2>
</header>
<p className={styles.usage}>
<span className={styles.usedNumber}>{usedMinutes}</span>
<span className={styles.usageUnit}>min/</span>
<span className={styles.totalNumber}>{totalMinutes}</span>
<span className={styles.usageUnit}>min</span>
<span className={styles.usageText}>{"\u00a0\u00a0"}used/remaining</span>
</p>
<div className={styles.footer}>
<button
type="button"
className={styles.buyBtn}
onClick={onBuyPackage}
aria-label="Buy Package"
>
Buy Package
</button>
</div>
</div>
<div className={styles.micIcon} aria-hidden="true">
<Image
src="/images/sidebar/ic_user_voicemessage.png"
alt=""
width={100}
height={100}
className={styles.micImage}
/>
</div>
</div>
</section>
);
}
+7 -33
View File
@@ -20,23 +20,8 @@ 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 VIP_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=vip`;
const TOP_UP_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=topup`;
const STATUS_LABELS: Record<SidebarUserState, string> = {
guest: "Sign in to keep your coins and unlock more features.",
member: "Manage your wallet, subscription and account settings.",
vip: "Your VIP benefits are active. Enjoy the full experience.",
};
/**
* 侧边栏主屏幕
*
* 根据 `loginStatus + currentUser.isVip` 派生三种状态:
* - guest:未登录
* - member:已登录未购买 VIP
* - vip:已登录且购买 VIP
*
*
*/
export function SidebarScreen() { export function SidebarScreen() {
const router = useRouter(); const router = useRouter();
const user = useUserState(); const user = useUserState();
@@ -74,12 +59,6 @@ export function SidebarScreen() {
<BackBar /> <BackBar />
</div> </div>
<section className={styles.hero}>
<p className={styles.kicker}>Cozsweet</p>
<h1 className={styles.title}>Account</h1>
<p className={styles.subtitle}>{STATUS_LABELS[sidebarState]}</p>
</section>
<section className={`${styles.userSlot} ${styles.revealOne}`}> <section className={`${styles.userSlot} ${styles.revealOne}`}>
<UserHeader <UserHeader
state={sidebarState} state={sidebarState}
@@ -93,21 +72,16 @@ export function SidebarScreen() {
<section className={`${styles.cardSlot} ${styles.revealTwo}`}> <section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<SidebarWalletCard <SidebarWalletCard
creditBalance={user.creditBalance} creditBalance={user.creditBalance}
onTopUp={() => router.push(VIP_SUBSCRIPTION_URL)} onActivateVip={
sidebarState === "vip"
? undefined
: () => router.push(VIP_SUBSCRIPTION_URL)
}
onTopUp={() => router.push(TOP_UP_SUBSCRIPTION_URL)}
onRulesClick={() => router.push(ROUTES.coinsRules)} onRulesClick={() => router.push(ROUTES.coinsRules)}
/> />
</section> </section>
{/*
<section className={styles.cardSlot}>
<VoicePackageCard
usedMinutes={0}
totalMinutes={0}
onBuyPackage={() => router.push(VOICE_SUBSCRIPTION_URL)}
/>
</section>
*/}
{sidebarState !== "guest" ? ( {sidebarState !== "guest" ? (
<section className={`${styles.settingsSlot} ${styles.revealThree}`}> <section className={`${styles.settingsSlot} ${styles.revealThree}`}>
<p className={styles.settingsLabel}>Other</p> <p className={styles.settingsLabel}>Other</p>
@@ -23,7 +23,7 @@ const log = new Logger("SubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps { export interface SubscriptionCheckoutButtonProps {
/** 是否可用(未选套餐 / 未勾选协议 → false) */ /** 是否可用(未选套餐 / 未勾选协议 → false) */
disabled?: boolean; disabled?: boolean;
subscriptionType: "vip" | "voice"; subscriptionType: "vip" | "topup";
returnTo?: "chat" | null; returnTo?: "chat" | null;
} }
@@ -97,13 +97,19 @@ export function SubscriptionCheckoutButton({
const isLoading = const isLoading =
payment.isCreatingOrder || payment.isCreatingOrder ||
payment.isPollingOrder; payment.isPollingOrder;
const readyLabel =
subscriptionType === "topup" ? "Pay and Top Up" : "Pay and Activiate";
const agreementLabel =
subscriptionType === "topup"
? "Confirm the agreement and Top Up"
: "Confirm the agreement and Activate";
const label = payment.isPollingOrder const label = payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
? "Creating order..." ? "Creating order..."
: payment.agreed : payment.agreed
? "Pay and Activiate" ? readyLabel
: "Confirm the agreement and Activate"; : agreementLabel;
const handleClick = () => { const handleClick = () => {
if (disabled || isLoading) return; if (disabled || isLoading) return;
@@ -244,7 +250,7 @@ function isEzpayPayment(payParams: Record<string, unknown>): boolean {
interface RedirectToEzpayInput { interface RedirectToEzpayInput {
orderId: string | null; orderId: string | null;
paymentUrl: string; paymentUrl: string;
subscriptionType: "vip" | "voice"; subscriptionType: "vip" | "topup";
returnTo?: "chat" | null; returnTo?: "chat" | null;
onFailed: (errorMessage: string) => void; onFailed: (errorMessage: string) => void;
} }
@@ -4,7 +4,7 @@ import styles from "./subscription-payment-success-dialog.module.css";
export interface SubscriptionPaymentSuccessDialogProps { export interface SubscriptionPaymentSuccessDialogProps {
open: boolean; open: boolean;
subscriptionType: "vip" | "voice"; subscriptionType: "vip" | "topup";
onClose: () => void; onClose: () => void;
} }
@@ -16,8 +16,8 @@ export function SubscriptionPaymentSuccessDialog({
if (!open) return null; if (!open) return null;
const content = const content =
subscriptionType === "voice" subscriptionType === "topup"
? "Your voice package purchase was completed successfully." ? "Your coins have been added to your wallet successfully."
: "Your VIP membership has been activated successfully."; : "Your VIP membership has been activated successfully.";
return ( return (
+1 -1
View File
@@ -10,7 +10,7 @@ import { ROUTES } from "@/router/routes";
type ReturnStatus = "loading" | "missing"; type ReturnStatus = "loading" | "missing";
function buildSubscriptionUrl( function buildSubscriptionUrl(
subscriptionType: "vip" | "voice", subscriptionType: "vip" | "topup",
returnTo?: "chat", returnTo?: "chat",
): string { ): string {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -8,7 +8,7 @@ import {
} from "./subscription-screen"; } from "./subscription-screen";
function toSubscriptionType(value: string | null): SubscriptionType { function toSubscriptionType(value: string | null): SubscriptionType {
return value === "voice" ? "voice" : "vip"; return value === "topup" ? "topup" : "vip";
} }
function toReturnTo(value: string | null): "chat" | null { function toReturnTo(value: string | null): "chat" | null {
@@ -1,15 +1,9 @@
import type { PaymentPlan } from "@/data/dto/payment"; import type { PaymentPlan } from "@/data/dto/payment";
import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section"; import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section";
import type { SubscriptionPlanView } from "./components/subscription-plan-card";
import type { VipOfferPlanView } from "./components/subscription-vip-offer-section"; import type { VipOfferPlanView } from "./components/subscription-vip-offer-section";
export type SubscriptionType = "vip" | "voice"; export type SubscriptionType = "vip" | "topup";
export const SUBSCRIPTION_BANNER_TITLES: Record<SubscriptionType, string> = {
vip: "Subscribe to become the VIP Member",
voice: "Buy Voice Message Package",
};
export function isVipPlan(plan: PaymentPlan): boolean { export function isVipPlan(plan: PaymentPlan): boolean {
return plan.vipDays !== null; return plan.vipDays !== null;
@@ -19,32 +13,6 @@ export function isCreditPlan(plan: PaymentPlan): boolean {
return plan.dolAmount !== null; return plan.dolAmount !== null;
} }
function isVoicePackagePlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase();
const planId = plan.planId.toLowerCase();
return (
isCreditPlan(plan) ||
orderType.includes("voice") ||
planId.includes("voice")
);
}
export function isPlanForType(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): boolean {
return subscriptionType === "voice"
? isVoicePackagePlan(plan)
: isVipPlan(plan);
}
function currencySymbol(currency: string): string {
const normalized = currency.toUpperCase();
if (normalized === "CNY") return "¥";
if (normalized === "USD") return "$";
return `${currency} `;
}
function formatAmount(amountCents: number | null): string | null { function formatAmount(amountCents: number | null): string | null {
if (amountCents === null) return null; if (amountCents === null) return null;
if (!Number.isFinite(amountCents)) return null; if (!Number.isFinite(amountCents)) return null;
@@ -78,40 +46,6 @@ function vipOfferTitle(plan: PaymentPlan): string {
return plan.planName; return plan.planName;
} }
function planCaption(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): string {
if (subscriptionType === "voice") {
return plan.dolAmount === null
? "Voice package"
: `${plan.dolAmount} voice messages`;
}
if (plan.dailyPriceCents !== null) {
const dailyPrice = formatAmount(plan.dailyPriceCents);
return dailyPrice === null
? "Lifetime"
: `${currencySymbol(plan.currency)}${dailyPrice}/day`;
}
if (plan.vipDays === null) return "Lifetime";
const fallbackDailyPrice = plan.amountCents / Math.max(plan.vipDays, 1);
return `${currencySymbol(plan.currency)}${formatAmount(fallbackDailyPrice)}/day`;
}
export function toPlanView(
plan: PaymentPlan,
subscriptionType: SubscriptionType,
): SubscriptionPlanView {
return {
id: plan.planId,
name: plan.planName,
price: formatAmount(plan.amountCents) ?? "",
originalPrice: formatAmount(plan.originalAmountCents),
perDay: planCaption(plan, subscriptionType),
currencySymbol: currencySymbol(plan.currency),
};
}
export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView { export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView {
return { return {
id: plan.planId, id: plan.planId,
+12 -82
View File
@@ -9,33 +9,26 @@ import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
} from "@/stores/payment/payment-context"; } from "@/stores/payment/payment-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { useUserDispatch } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { import {
SubscriptionBackLink, SubscriptionBackLink,
SubscriptionBanner,
SubscriptionCheckoutButton, SubscriptionCheckoutButton,
SubscriptionCoinsOfferSection, SubscriptionCoinsOfferSection,
SubscriptionPaymentMethod, SubscriptionPaymentMethod,
SubscriptionPaymentSuccessDialog, SubscriptionPaymentSuccessDialog,
SubscriptionPlanCard,
SubscriptionUserRow,
SubscriptionVipOfferSection, SubscriptionVipOfferSection,
} from "./components"; } from "./components";
import styles from "./components/subscription-screen.module.css"; import styles from "./components/subscription-screen.module.css";
import { import {
SUBSCRIPTION_BANNER_TITLES,
isCreditPlan, isCreditPlan,
isPlanForType,
isVipPlan, isVipPlan,
toCoinsOfferPlanView, toCoinsOfferPlanView,
toPlanView,
toVipOfferPlanView, toVipOfferPlanView,
type SubscriptionType, type SubscriptionType,
} from "./subscription-screen.helpers"; } from "./subscription-screen.helpers";
const FALLBACK_USERNAME = "User name";
export type { SubscriptionType } from "./subscription-screen.helpers"; export type { SubscriptionType } from "./subscription-screen.helpers";
export interface SubscriptionScreenProps { export interface SubscriptionScreenProps {
@@ -50,7 +43,6 @@ export function SubscriptionScreen({
returnTo = null, returnTo = null,
}: SubscriptionScreenProps) { }: SubscriptionScreenProps) {
const router = useRouter(); const router = useRouter();
const user = useUserState();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
@@ -59,7 +51,7 @@ export function SubscriptionScreen({
const successDialogShownOrderRef = useRef<string | null>(null); const successDialogShownOrderRef = useRef<string | null>(null);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
useState(false); useState(false);
const isVipSubscription = subscriptionType === "vip"; const canSubscribeVip = subscriptionType === "vip";
useEffect(() => { useEffect(() => {
if (payment.status === "idle") { if (payment.status === "idle") {
@@ -139,14 +131,6 @@ export function SubscriptionScreen({
void PendingPaymentOrderStorage.clearPendingOrder(); void PendingPaymentOrderStorage.clearPendingOrder();
}, [payment.currentOrderId, payment.isPaid, payment.status]); }, [payment.currentOrderId, payment.isPaid, payment.status]);
const plans = useMemo(
() =>
payment.plans
.filter((plan) => isPlanForType(plan, subscriptionType))
.slice(0, 3)
.map((plan) => toPlanView(plan, subscriptionType)),
[payment.plans, subscriptionType],
);
const vipOfferPlans = useMemo( const vipOfferPlans = useMemo(
() => () =>
payment.plans payment.plans
@@ -161,28 +145,18 @@ export function SubscriptionScreen({
); );
const selectedPlan = const selectedPlan =
(isVipSubscription (canSubscribeVip
? [...vipOfferPlans, ...directCoinsPlans].find( ? [...vipOfferPlans, ...directCoinsPlans].find(
(plan) => plan.id === payment.selectedPlanId, (plan) => plan.id === payment.selectedPlanId,
) )
: plans.find((plan) => plan.id === payment.selectedPlanId)) ?? null; : directCoinsPlans.find((plan) => plan.id === payment.selectedPlanId)) ??
null;
const isPaymentBusy = const isPaymentBusy =
payment.isCreatingOrder || payment.isCreatingOrder ||
payment.isPollingOrder; payment.isPollingOrder;
const canActivate = const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy; selectedPlan !== null && payment.agreed && !isPaymentBusy;
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
const plansStatusMessage =
subscriptionType === "voice"
? "Voice packages are unavailable."
: "Membership plans are unavailable.";
const plansAriaLabel =
subscriptionType === "voice"
? "Choose a voice package"
: "Choose a subscription plan";
const finishPaymentSuccessClose = () => { const finishPaymentSuccessClose = () => {
setShowPaymentSuccessDialog(false); setShowPaymentSuccessDialog(false);
paymentDispatch({ type: "PaymentReset" }); paymentDispatch({ type: "PaymentReset" });
@@ -196,7 +170,7 @@ export function SubscriptionScreen({
}; };
useEffect(() => { useEffect(() => {
if (isVipSubscription) { if (canSubscribeVip) {
const firstVipPlanId = vipOfferPlans[0]?.id ?? ""; const firstVipPlanId = vipOfferPlans[0]?.id ?? "";
const hasSelectedVipPlan = vipOfferPlans.some( const hasSelectedVipPlan = vipOfferPlans.some(
(plan) => plan.id === payment.selectedPlanId, (plan) => plan.id === payment.selectedPlanId,
@@ -219,28 +193,21 @@ export function SubscriptionScreen({
return; return;
} }
if (payment.isLoadingPlans || payment.plans.length === 0) return; if (payment.isLoadingPlans || directCoinsPlans.length === 0) return;
if (selectedPlan !== null) return; if (selectedPlan !== null) return;
const nextPlan = payment.plans.find((plan) =>
isPlanForType(plan, subscriptionType),
);
if (!nextPlan) return;
paymentDispatch({ paymentDispatch({
type: "PaymentPlanSelected", type: "PaymentPlanSelected",
planId: nextPlan.planId, planId: directCoinsPlans[0]?.id ?? "",
}); });
}, [ }, [
canSubscribeVip,
directCoinsPlans,
payment.isLoadingPlans, payment.isLoadingPlans,
payment.plans,
paymentDispatch, paymentDispatch,
payment.selectedPlanId, payment.selectedPlanId,
payment.status, payment.status,
directCoinsPlans,
isVipSubscription,
selectedPlan, selectedPlan,
subscriptionType,
vipOfferPlans, vipOfferPlans,
]); ]);
@@ -251,8 +218,8 @@ export function SubscriptionScreen({
<SubscriptionBackLink className={styles.backSlot} /> <SubscriptionBackLink className={styles.backSlot} />
</header> </header>
{isVipSubscription ? (
<div className={styles.offerStack}> <div className={styles.offerStack}>
{canSubscribeVip ? (
<SubscriptionVipOfferSection <SubscriptionVipOfferSection
plans={vipOfferPlans} plans={vipOfferPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null} selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
@@ -263,6 +230,7 @@ export function SubscriptionScreen({
}) })
} }
/> />
) : null}
<SubscriptionCoinsOfferSection <SubscriptionCoinsOfferSection
plans={directCoinsPlans} plans={directCoinsPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null} selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
@@ -274,44 +242,6 @@ export function SubscriptionScreen({
} }
/> />
</div> </div>
) : (
<>
<section className={styles.userSlot}>
<SubscriptionUserRow name={name} avatarUrl={avatarUrl} />
</section>
<section className={styles.bannerSlot}>
<SubscriptionBanner
title={SUBSCRIPTION_BANNER_TITLES[subscriptionType]}
/>
</section>
<section className={styles.plansRow} aria-label={plansAriaLabel}>
{plans.length > 0 ? (
plans.map((plan, index) => (
<SubscriptionPlanCard
key={plan.id}
plan={plan}
gradientIndex={index}
selected={payment.selectedPlanId === plan.id}
onClick={() =>
paymentDispatch({
type: "PaymentPlanSelected",
planId: plan.id,
})
}
/>
))
) : (
<p className={styles.plansStatus}>
{payment.isLoadingPlans
? "Loading membership plans..."
: payment.errorMessage ?? plansStatusMessage}
</p>
)}
</section>
</>
)}
<section className={styles.paymentMethodSlot}> <section className={styles.paymentMethodSlot}>
<SubscriptionPaymentMethod <SubscriptionPaymentMethod
+1 -2
View File
@@ -85,8 +85,7 @@ export default function SubscriptionSuccessPage() {
🎉 Subscription activated! 🎉 Subscription activated!
</h1> </h1>
<p style={{ color: "#666" }}> <p style={{ color: "#666" }}>
Welcome to VIP. Enjoy unlimited chat, free voice minutes, and Welcome to VIP. Enjoy unlimited chat and more premium benefits.
more.
</p> </p>
<button <button
type="button" type="button"
@@ -9,7 +9,7 @@ import { StorageKeys } from "../storage_keys";
const PendingPaymentOrderSchema = z.object({ const PendingPaymentOrderSchema = z.object({
orderId: z.string().min(1), orderId: z.string().min(1),
payChannel: z.literal("ezpay"), payChannel: z.literal("ezpay"),
subscriptionType: z.enum(["vip", "voice"]), subscriptionType: z.enum(["vip", "topup"]),
returnTo: z.enum(["chat"]).optional(), returnTo: z.enum(["chat"]).optional(),
createdAt: z.number(), createdAt: z.number(),
}); });
+1 -1
View File
@@ -32,7 +32,7 @@ export const ROUTE_BUILDERS = {
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` => chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const, `/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
subscription: ( subscription: (
type: "vip" | "voice", type: "vip" | "topup",
options: { returnTo?: "chat" } = {}, options: { returnTo?: "chat" } = {},
): `/subscription?${string}` => { ): `/subscription?${string}` => {
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
-4
View File
@@ -95,10 +95,6 @@
/* 卡片纯白表面 */ /* 卡片纯白表面 */
--color-card-surface: #ffffff; --color-card-surface: #ffffff;
/* Voice Message Package 边框/图标渐变(pink → orange */
--color-voice-gradient-start: #ff6fa3;
--color-voice-gradient-end: #ff9a5a;
/* 卡片描边粗细 */ /* 卡片描边粗细 */
--border-card: 1.5px; --border-card: 1.5px;