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/vip-benefits-card.tsx` 的 "Activate VIP Membership" 行为一致
|
||||||
* - 与 `src/app/sidebar/components/voice-package-card.tsx` 的 "Buy Package" 行为一致
|
* - 与订阅页 VIP 入口行为一致
|
||||||
*
|
*
|
||||||
* 不做的事:
|
* 不做的事:
|
||||||
* - 不直接管理 state machine(chat 机器不感知 UI 层)
|
* - 不直接管理 state machine(chat 机器不感知 UI 层)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<button type="button" className={styles.topUpButton} onClick={onTopUp}>
|
<div className={styles.actionColumn}>
|
||||||
Top Up
|
{onActivateVip ? (
|
||||||
</button>
|
<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>
|
</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 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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,55 +230,18 @@ export function SubscriptionScreen({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SubscriptionCoinsOfferSection
|
) : null}
|
||||||
plans={directCoinsPlans}
|
<SubscriptionCoinsOfferSection
|
||||||
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
|
plans={directCoinsPlans}
|
||||||
onSelectPlan={(planId) =>
|
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
|
||||||
paymentDispatch({
|
onSelectPlan={(planId) =>
|
||||||
type: "PaymentPlanSelected",
|
paymentDispatch({
|
||||||
planId,
|
type: "PaymentPlanSelected",
|
||||||
})
|
planId,
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
</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
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user