feat(subscription): split vip and topup flows
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
*
|
||||
* 业务关系:
|
||||
* - 与 `src/app/sidebar/components/vip-benefits-card.tsx` 的 "Activate VIP Membership" 行为一致
|
||||
* - 与 `src/app/sidebar/components/voice-package-card.tsx` 的 "Buy Package" 行为一致
|
||||
* - 与订阅页 VIP 入口行为一致
|
||||
*
|
||||
* 不做的事:
|
||||
* - 不直接管理 state machine(chat 机器不感知 UI 层)
|
||||
|
||||
@@ -6,4 +6,3 @@ export * from "./back-bar";
|
||||
export * from "./sidebar-wallet-card";
|
||||
export * from "./user-header";
|
||||
export * from "./vip-benefits-card";
|
||||
export * from "./voice-package-card";
|
||||
|
||||
@@ -98,26 +98,52 @@
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.actionColumn {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.activateButton,
|
||||
.topUpButton {
|
||||
display: inline-flex;
|
||||
min-width: 92px;
|
||||
min-height: 48px;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border: 1px solid transparent;
|
||||
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:
|
||||
linear-gradient(135deg, #f657a0 0%, #ff7ac3 100%),
|
||||
#f657a0;
|
||||
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);
|
||||
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 {
|
||||
@@ -125,12 +151,14 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.activateButton:active,
|
||||
.topUpButton:active {
|
||||
opacity: 0.92;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.rulesButton:focus-visible,
|
||||
.activateButton:focus-visible,
|
||||
.topUpButton:focus-visible {
|
||||
outline: 2px solid #f657a0;
|
||||
outline-offset: 3px;
|
||||
@@ -142,11 +170,12 @@
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.activateButton,
|
||||
.topUpButton {
|
||||
min-width: 78px;
|
||||
min-height: 44px;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.balanceValue {
|
||||
|
||||
@@ -6,6 +6,7 @@ import styles from "./sidebar-wallet-card.module.css";
|
||||
|
||||
export interface SidebarWalletCardProps {
|
||||
creditBalance: number;
|
||||
onActivateVip?: () => void;
|
||||
onTopUp: () => void;
|
||||
onRulesClick: () => void;
|
||||
}
|
||||
@@ -15,6 +16,7 @@ const formatCoins = (value: number): string =>
|
||||
|
||||
export function SidebarWalletCard({
|
||||
creditBalance,
|
||||
onActivateVip,
|
||||
onTopUp,
|
||||
onRulesClick,
|
||||
}: SidebarWalletCardProps) {
|
||||
@@ -42,9 +44,20 @@ export function SidebarWalletCard({
|
||||
</button>
|
||||
</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}>
|
||||
Top Up
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -20,23 +20,8 @@ import styles from "./components/sidebar-screen.module.css";
|
||||
|
||||
const FALLBACK_USERNAME = "User name";
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const user = useUserState();
|
||||
@@ -74,12 +59,6 @@ export function SidebarScreen() {
|
||||
<BackBar />
|
||||
</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}`}>
|
||||
<UserHeader
|
||||
state={sidebarState}
|
||||
@@ -93,21 +72,16 @@ export function SidebarScreen() {
|
||||
<section className={`${styles.cardSlot} ${styles.revealTwo}`}>
|
||||
<SidebarWalletCard
|
||||
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)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
<section className={styles.cardSlot}>
|
||||
<VoicePackageCard
|
||||
usedMinutes={0}
|
||||
totalMinutes={0}
|
||||
onBuyPackage={() => router.push(VOICE_SUBSCRIPTION_URL)}
|
||||
/>
|
||||
</section>
|
||||
*/}
|
||||
|
||||
{sidebarState !== "guest" ? (
|
||||
<section className={`${styles.settingsSlot} ${styles.revealThree}`}>
|
||||
<p className={styles.settingsLabel}>Other</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ const log = new Logger("SubscriptionCheckoutButton");
|
||||
export interface SubscriptionCheckoutButtonProps {
|
||||
/** 是否可用(未选套餐 / 未勾选协议 → false) */
|
||||
disabled?: boolean;
|
||||
subscriptionType: "vip" | "voice";
|
||||
subscriptionType: "vip" | "topup";
|
||||
returnTo?: "chat" | null;
|
||||
}
|
||||
|
||||
@@ -97,13 +97,19 @@ export function SubscriptionCheckoutButton({
|
||||
const isLoading =
|
||||
payment.isCreatingOrder ||
|
||||
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
|
||||
? "Processing payment..."
|
||||
: payment.isCreatingOrder
|
||||
? "Creating order..."
|
||||
: payment.agreed
|
||||
? "Pay and Activiate"
|
||||
: "Confirm the agreement and Activate";
|
||||
? readyLabel
|
||||
: agreementLabel;
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled || isLoading) return;
|
||||
@@ -244,7 +250,7 @@ function isEzpayPayment(payParams: Record<string, unknown>): boolean {
|
||||
interface RedirectToEzpayInput {
|
||||
orderId: string | null;
|
||||
paymentUrl: string;
|
||||
subscriptionType: "vip" | "voice";
|
||||
subscriptionType: "vip" | "topup";
|
||||
returnTo?: "chat" | null;
|
||||
onFailed: (errorMessage: string) => void;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import styles from "./subscription-payment-success-dialog.module.css";
|
||||
|
||||
export interface SubscriptionPaymentSuccessDialogProps {
|
||||
open: boolean;
|
||||
subscriptionType: "vip" | "voice";
|
||||
subscriptionType: "vip" | "topup";
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export function SubscriptionPaymentSuccessDialog({
|
||||
if (!open) return null;
|
||||
|
||||
const content =
|
||||
subscriptionType === "voice"
|
||||
? "Your voice package purchase was completed successfully."
|
||||
subscriptionType === "topup"
|
||||
? "Your coins have been added to your wallet successfully."
|
||||
: "Your VIP membership has been activated successfully.";
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ROUTES } from "@/router/routes";
|
||||
type ReturnStatus = "loading" | "missing";
|
||||
|
||||
function buildSubscriptionUrl(
|
||||
subscriptionType: "vip" | "voice",
|
||||
subscriptionType: "vip" | "topup",
|
||||
returnTo?: "chat",
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./subscription-screen";
|
||||
|
||||
function toSubscriptionType(value: string | null): SubscriptionType {
|
||||
return value === "voice" ? "voice" : "vip";
|
||||
return value === "topup" ? "topup" : "vip";
|
||||
}
|
||||
|
||||
function toReturnTo(value: string | null): "chat" | null {
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import type { PaymentPlan } from "@/data/dto/payment";
|
||||
|
||||
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";
|
||||
|
||||
export type SubscriptionType = "vip" | "voice";
|
||||
|
||||
export const SUBSCRIPTION_BANNER_TITLES: Record<SubscriptionType, string> = {
|
||||
vip: "Subscribe to become the VIP Member",
|
||||
voice: "Buy Voice Message Package",
|
||||
};
|
||||
export type SubscriptionType = "vip" | "topup";
|
||||
|
||||
export function isVipPlan(plan: PaymentPlan): boolean {
|
||||
return plan.vipDays !== null;
|
||||
@@ -19,32 +13,6 @@ export function isCreditPlan(plan: PaymentPlan): boolean {
|
||||
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 {
|
||||
if (amountCents === null) return null;
|
||||
if (!Number.isFinite(amountCents)) return null;
|
||||
@@ -78,40 +46,6 @@ function vipOfferTitle(plan: PaymentPlan): string {
|
||||
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 {
|
||||
return {
|
||||
id: plan.planId,
|
||||
|
||||
@@ -9,33 +9,26 @@ import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
} 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 {
|
||||
SubscriptionBackLink,
|
||||
SubscriptionBanner,
|
||||
SubscriptionCheckoutButton,
|
||||
SubscriptionCoinsOfferSection,
|
||||
SubscriptionPaymentMethod,
|
||||
SubscriptionPaymentSuccessDialog,
|
||||
SubscriptionPlanCard,
|
||||
SubscriptionUserRow,
|
||||
SubscriptionVipOfferSection,
|
||||
} from "./components";
|
||||
import styles from "./components/subscription-screen.module.css";
|
||||
import {
|
||||
SUBSCRIPTION_BANNER_TITLES,
|
||||
isCreditPlan,
|
||||
isPlanForType,
|
||||
isVipPlan,
|
||||
toCoinsOfferPlanView,
|
||||
toPlanView,
|
||||
toVipOfferPlanView,
|
||||
type SubscriptionType,
|
||||
} from "./subscription-screen.helpers";
|
||||
|
||||
const FALLBACK_USERNAME = "User name";
|
||||
export type { SubscriptionType } from "./subscription-screen.helpers";
|
||||
|
||||
export interface SubscriptionScreenProps {
|
||||
@@ -50,7 +43,6 @@ export function SubscriptionScreen({
|
||||
returnTo = null,
|
||||
}: SubscriptionScreenProps) {
|
||||
const router = useRouter();
|
||||
const user = useUserState();
|
||||
const userDispatch = useUserDispatch();
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
@@ -59,7 +51,7 @@ export function SubscriptionScreen({
|
||||
const successDialogShownOrderRef = useRef<string | null>(null);
|
||||
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
|
||||
useState(false);
|
||||
const isVipSubscription = subscriptionType === "vip";
|
||||
const canSubscribeVip = subscriptionType === "vip";
|
||||
|
||||
useEffect(() => {
|
||||
if (payment.status === "idle") {
|
||||
@@ -139,14 +131,6 @@ export function SubscriptionScreen({
|
||||
void PendingPaymentOrderStorage.clearPendingOrder();
|
||||
}, [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(
|
||||
() =>
|
||||
payment.plans
|
||||
@@ -161,28 +145,18 @@ export function SubscriptionScreen({
|
||||
);
|
||||
|
||||
const selectedPlan =
|
||||
(isVipSubscription
|
||||
(canSubscribeVip
|
||||
? [...vipOfferPlans, ...directCoinsPlans].find(
|
||||
(plan) => plan.id === payment.selectedPlanId,
|
||||
)
|
||||
: plans.find((plan) => plan.id === payment.selectedPlanId)) ?? null;
|
||||
: directCoinsPlans.find((plan) => plan.id === payment.selectedPlanId)) ??
|
||||
null;
|
||||
const isPaymentBusy =
|
||||
payment.isCreatingOrder ||
|
||||
payment.isPollingOrder;
|
||||
const canActivate =
|
||||
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 = () => {
|
||||
setShowPaymentSuccessDialog(false);
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
@@ -196,7 +170,7 @@ export function SubscriptionScreen({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isVipSubscription) {
|
||||
if (canSubscribeVip) {
|
||||
const firstVipPlanId = vipOfferPlans[0]?.id ?? "";
|
||||
const hasSelectedVipPlan = vipOfferPlans.some(
|
||||
(plan) => plan.id === payment.selectedPlanId,
|
||||
@@ -219,28 +193,21 @@ export function SubscriptionScreen({
|
||||
return;
|
||||
}
|
||||
|
||||
if (payment.isLoadingPlans || payment.plans.length === 0) return;
|
||||
if (payment.isLoadingPlans || directCoinsPlans.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,
|
||||
planId: directCoinsPlans[0]?.id ?? "",
|
||||
});
|
||||
}, [
|
||||
canSubscribeVip,
|
||||
directCoinsPlans,
|
||||
payment.isLoadingPlans,
|
||||
payment.plans,
|
||||
paymentDispatch,
|
||||
payment.selectedPlanId,
|
||||
payment.status,
|
||||
directCoinsPlans,
|
||||
isVipSubscription,
|
||||
selectedPlan,
|
||||
subscriptionType,
|
||||
vipOfferPlans,
|
||||
]);
|
||||
|
||||
@@ -251,8 +218,8 @@ export function SubscriptionScreen({
|
||||
<SubscriptionBackLink className={styles.backSlot} />
|
||||
</header>
|
||||
|
||||
{isVipSubscription ? (
|
||||
<div className={styles.offerStack}>
|
||||
{canSubscribeVip ? (
|
||||
<SubscriptionVipOfferSection
|
||||
plans={vipOfferPlans}
|
||||
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
|
||||
@@ -263,6 +230,7 @@ export function SubscriptionScreen({
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<SubscriptionCoinsOfferSection
|
||||
plans={directCoinsPlans}
|
||||
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
|
||||
@@ -274,44 +242,6 @@ export function SubscriptionScreen({
|
||||
}
|
||||
/>
|
||||
</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}>
|
||||
<SubscriptionPaymentMethod
|
||||
|
||||
@@ -85,8 +85,7 @@ export default function SubscriptionSuccessPage() {
|
||||
🎉 Subscription activated!
|
||||
</h1>
|
||||
<p style={{ color: "#666" }}>
|
||||
Welcome to VIP. Enjoy unlimited chat, free voice minutes, and
|
||||
more.
|
||||
Welcome to VIP. Enjoy unlimited chat and more premium benefits.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { StorageKeys } from "../storage_keys";
|
||||
const PendingPaymentOrderSchema = z.object({
|
||||
orderId: z.string().min(1),
|
||||
payChannel: z.literal("ezpay"),
|
||||
subscriptionType: z.enum(["vip", "voice"]),
|
||||
subscriptionType: z.enum(["vip", "topup"]),
|
||||
returnTo: z.enum(["chat"]).optional(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ROUTE_BUILDERS = {
|
||||
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
|
||||
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
|
||||
subscription: (
|
||||
type: "vip" | "voice",
|
||||
type: "vip" | "topup",
|
||||
options: { returnTo?: "chat" } = {},
|
||||
): `/subscription?${string}` => {
|
||||
const params = new URLSearchParams({ type });
|
||||
|
||||
@@ -95,10 +95,6 @@
|
||||
/* 卡片纯白表面 */
|
||||
--color-card-surface: #ffffff;
|
||||
|
||||
/* Voice Message Package 边框/图标渐变(pink → orange) */
|
||||
--color-voice-gradient-start: #ff6fa3;
|
||||
--color-voice-gradient-end: #ff9a5a;
|
||||
|
||||
/* 卡片描边粗细 */
|
||||
--border-card: 1.5px;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user