feat(subscription): add VIP subscription page
Add a new /subscription route showing three VIP membership plans (Monthly, Quarterly, Annual) with a pink→peach banner, per-plan gradient price bars, a benefits card, a mock activate CTA, and an agreement checkbox. - New reusable <Checkbox /> in src/app/_components/core for the legal agreement toggle - Hardcoded SUBSCRIPTION_PLANS constant in src/data/constants - New SubscriptionScreen composed of 7 colocated components (back link, user row, banner, plan card, benefits card, CTA button) with CSS Modules matching the existing design-token system - Route registered as protected and reached from /sidebar "Membership" - Mock CTA: window.alert + router.push(/chat) — no payment calls Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"./src/app/chat/components",
|
"./src/app/chat/components",
|
||||||
"./src/app/sidebar/components",
|
"./src/app/sidebar/components",
|
||||||
"./src/app/splash/components",
|
"./src/app/splash/components",
|
||||||
|
"./src/app/subscription/components",
|
||||||
"./src/hooks",
|
"./src/hooks",
|
||||||
"./src/integrations",
|
"./src/integrations",
|
||||||
"./src/models/auth",
|
"./src/models/auth",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/* 通用圆形复选框
|
||||||
|
*
|
||||||
|
* 视觉规格:
|
||||||
|
* - 16px 白色圆形复选框 + 1px 浅阴影
|
||||||
|
* - 选中时 10px 粉色实心圆
|
||||||
|
* - 行内标签可点击
|
||||||
|
*/
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--auth-legal-checkbox-size);
|
||||||
|
height: var(--auth-legal-checkbox-size);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-auth-surface);
|
||||||
|
border: 1px solid var(--color-auth-surface);
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 2px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxInner {
|
||||||
|
display: block;
|
||||||
|
width: var(--auth-legal-checkbox-inner-size);
|
||||||
|
height: var(--auth-legal-checkbox-inner-size);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-auth-legal-check);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--color-auth-text-primary);
|
||||||
|
text-align: left;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* 通用圆形复选框
|
||||||
|
*
|
||||||
|
* 视觉规格(与 `auth-legal-text` 一致):
|
||||||
|
* - 16px 白色圆形
|
||||||
|
* - 选中时 10px 粉色实心圆
|
||||||
|
* - 提供可选 `label`,与复选框一起点击可切换
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import styles from "./checkbox.module.css";
|
||||||
|
|
||||||
|
export interface CheckboxProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (next: boolean) => void;
|
||||||
|
/** 可选标签;提供时点击文字也能切换 */
|
||||||
|
label?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** 当未提供 label 时使用 */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
ariaLabel,
|
||||||
|
}: CheckboxProps) {
|
||||||
|
const button = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={[styles.checkbox, disabled ? styles.disabled : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
{checked ? <span className={styles.checkboxInner} /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (label == null) {
|
||||||
|
return (
|
||||||
|
<span className={className} style={{ display: "inline-flex" }}>
|
||||||
|
{button}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={[styles.row, className].filter(Boolean).join(" ")}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
<span className={styles.text}>{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export * from "./auth-back-button";
|
export * from "./auth-back-button";
|
||||||
export * from "./bottom-sheet";
|
export * from "./bottom-sheet";
|
||||||
|
export * from "./checkbox";
|
||||||
export * from "./dialog";
|
export * from "./dialog";
|
||||||
export * from "./loading-indicator";
|
export * from "./loading-indicator";
|
||||||
export * from "./mobile-shell";
|
export * from "./mobile-shell";
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ 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",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @file Automatically generated by barrelsby.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./subscription-back-link";
|
||||||
|
export * from "./subscription-banner";
|
||||||
|
export * from "./subscription-benefits-card";
|
||||||
|
export * from "./subscription-cta-button";
|
||||||
|
export * from "./subscription-plan-card";
|
||||||
|
export * from "./subscription-screen";
|
||||||
|
export * from "./subscription-user-row";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.backLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
margin-left: -8px;
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLink:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* 订阅页返回箭头
|
||||||
|
*
|
||||||
|
* 视觉规格(与设计稿对齐):
|
||||||
|
* - 24×24 SVG chevron-left
|
||||||
|
* - 黑色,2px 描边
|
||||||
|
* - 无 "Back" 文本
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
|
import styles from "./subscription-back-link.module.css";
|
||||||
|
|
||||||
|
export interface SubscriptionBackLinkProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionBackLink({ className }: SubscriptionBackLinkProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={ROUTES.sidebar}
|
||||||
|
aria-label="Back"
|
||||||
|
className={[styles.backLink, className].filter(Boolean).join(" ")}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={styles.icon}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15 6l-6 6 6 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.banner {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(90deg, #f96ade 0%, #ffb88a 100%);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--spacing-lg) var(--spacing-md);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 向下指的 CSS 三角 */
|
||||||
|
.pointer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 10px solid transparent;
|
||||||
|
border-right: 10px solid transparent;
|
||||||
|
border-top: 10px solid #ffb88a;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* 订阅页渐变横幅
|
||||||
|
*
|
||||||
|
* 视觉规格(与设计稿对齐):
|
||||||
|
* - 粉→桃渐变背景(90deg)
|
||||||
|
* - 居中白色 17px/600 文字
|
||||||
|
* - 底部中央有一个向下指的 CSS 三角
|
||||||
|
*/
|
||||||
|
import styles from "./subscription-banner.module.css";
|
||||||
|
|
||||||
|
export interface SubscriptionBannerProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionBanner({ className }: SubscriptionBannerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[styles.banner, className].filter(Boolean).join(" ")}
|
||||||
|
role="note"
|
||||||
|
>
|
||||||
|
<p className={styles.text}>Subscribe to become the VIP Member</p>
|
||||||
|
<span className={styles.pointer} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-auth-primary-gradient-start),
|
||||||
|
var(--color-auth-primary-gradient-end)
|
||||||
|
);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
color: var(--color-auth-text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item + .item {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.numeral {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 20px;
|
||||||
|
color: var(--color-auth-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* 订阅权益卡片
|
||||||
|
*
|
||||||
|
* 视觉规格(与设计稿对齐):
|
||||||
|
* - 白色卡片,--radius-xl 圆角
|
||||||
|
* - 顶部粉色渐变条:居中白色标题
|
||||||
|
* - 主体:1./2./3./4. 编号列表
|
||||||
|
* - 行间细分隔线
|
||||||
|
*/
|
||||||
|
import styles from "./subscription-benefits-card.module.css";
|
||||||
|
|
||||||
|
export interface SubscriptionBenefitsCardProps {
|
||||||
|
title: string;
|
||||||
|
items: readonly string[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionBenefitsCard({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
}: SubscriptionBenefitsCardProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={[styles.card, className].filter(Boolean).join(" ")}
|
||||||
|
>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h2 className={styles.title}>{title}</h2>
|
||||||
|
</header>
|
||||||
|
<ol className={styles.list}>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li key={idx} className={styles.item}>
|
||||||
|
<span className={styles.numeral}>{idx + 1}.</span>
|
||||||
|
<span className={styles.text}>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* 视觉规格:
|
||||||
|
* - 全宽,52px 高
|
||||||
|
* - 粉渐变(#f96ADE → #f657A0)
|
||||||
|
* - 半径 24,白字 16px/600
|
||||||
|
* - 阴影 0 3px 5px rgba(208,12,65,0.20)
|
||||||
|
*/
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 var(--spacing-lg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-xxxl);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-auth-primary-gradient-start),
|
||||||
|
var(--color-auth-primary-gradient-end)
|
||||||
|
);
|
||||||
|
box-shadow: 0 3px 5px var(--color-auth-primary-button-shadow);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s ease, transform 0.05s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:not(:disabled):active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-width: 2px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #ffffff transparent transparent transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* 订阅页主操作按钮
|
||||||
|
*
|
||||||
|
* 视觉规格(与设计稿对齐):
|
||||||
|
* - 全宽,52px 高,--radius-xxxl 圆角
|
||||||
|
* - 粉色渐变(#f96ADE → #f657A0)
|
||||||
|
* - 白字 16px/600 + 粉色阴影
|
||||||
|
* - :disabled 灰化
|
||||||
|
*/
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
import styles from "./subscription-cta-button.module.css";
|
||||||
|
|
||||||
|
export interface SubscriptionCtaButtonProps
|
||||||
|
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionCtaButton({
|
||||||
|
children,
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
}: SubscriptionCtaButtonProps) {
|
||||||
|
const classes = [styles.button, className].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={classes}
|
||||||
|
>
|
||||||
|
<span className={styles.label}>
|
||||||
|
{isLoading ? <span className={styles.spinner} aria-hidden="true" /> : null}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-xs) 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease,
|
||||||
|
transform 0.05s ease;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:not(:disabled):active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 4px 12px rgba(248, 77, 150, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
border-width: 2px;
|
||||||
|
box-shadow: 0 6px 16px rgba(248, 77, 150, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-auth-text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceBlock {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 400;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.originalPrice {
|
||||||
|
margin: 4px 0 var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: #999999;
|
||||||
|
text-decoration: line-through;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perDayBar {
|
||||||
|
align-self: stretch;
|
||||||
|
margin: 0 calc(-1 * var(--spacing-xs)) 0;
|
||||||
|
padding: 6px 0;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* 订阅套餐卡片
|
||||||
|
*
|
||||||
|
* 视觉规格(与设计稿对齐):
|
||||||
|
* - 卡片顶部:套餐名(12px / secondary)
|
||||||
|
* - 中部:大字价格(28px / 300)+ 划线原价(12px / line-through / 999)
|
||||||
|
* - 底部 28px 高渐变条:白字 12px/600 每日折算价
|
||||||
|
* - 选中/高亮:2px 粉色边框 + 浅粉阴影
|
||||||
|
* - 三个卡片底部渐变不同:粉 → 紫红 → 紫
|
||||||
|
*/
|
||||||
|
import type { SubscriptionPlan, SubscriptionPlanId } from "@/data/constants/subscription-plans";
|
||||||
|
|
||||||
|
import styles from "./subscription-plan-card.module.css";
|
||||||
|
|
||||||
|
const PER_DAY_GRADIENTS: Record<SubscriptionPlanId, string> = {
|
||||||
|
monthly: "linear-gradient(90deg, #ff7ab8 0%, #ff5d9c 100%)",
|
||||||
|
quarterly: "linear-gradient(90deg, #d16bff 0%, #ff5da3 100%)",
|
||||||
|
annual: "linear-gradient(90deg, #9a4dff 0%, #c44dff 100%)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SubscriptionPlanCardProps {
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionPlanCard({
|
||||||
|
plan,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: SubscriptionPlanCardProps) {
|
||||||
|
const classes = [
|
||||||
|
styles.card,
|
||||||
|
selected ? styles.selected : "",
|
||||||
|
!selected && plan.highlight ? styles.highlight : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const perDayStyle = { background: PER_DAY_GRADIENTS[plan.id] };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={`${plan.name} plan, $${plan.price}`}
|
||||||
|
className={classes}
|
||||||
|
>
|
||||||
|
<p className={styles.name}>{plan.name}</p>
|
||||||
|
<div className={styles.priceBlock}>
|
||||||
|
<span className={styles.currency}>$</span>
|
||||||
|
<span className={styles.price}>{plan.price}</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.originalPrice}>${plan.originalPrice}</p>
|
||||||
|
<div className={styles.perDayBar} style={perDayStyle}>
|
||||||
|
{plan.perDay}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--color-page-background);
|
||||||
|
padding: 0 var(--spacing-lg) var(--spacing-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
padding-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backSlot {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userSlot {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: 0 var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerSlot {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plansRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autoRenewCaption {
|
||||||
|
margin: var(--spacing-md) 0 0;
|
||||||
|
padding: 0 var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefitsSlot {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctaSlot {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreementSlot {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: 0 var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreementLink {
|
||||||
|
color: var(--color-auth-text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreementLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* 订阅页主屏幕
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/subscription/...
|
||||||
|
*
|
||||||
|
* 布局(顶到底):
|
||||||
|
* 1. 顶部返回箭头
|
||||||
|
* 2. 用户信息行
|
||||||
|
* 3. 渐变横幅
|
||||||
|
* 4. 三个套餐卡片
|
||||||
|
* 5. 自动续费说明
|
||||||
|
* 6. 权益列表卡片
|
||||||
|
* 7. 主操作按钮
|
||||||
|
* 8. 协议复选框
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { Checkbox } from "@/app/_components/core/checkbox";
|
||||||
|
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||||
|
import { AppConstants } from "@/core/app_constants";
|
||||||
|
import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans";
|
||||||
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import { useUserState } from "@/stores/user/user-context";
|
||||||
|
|
||||||
|
import { SubscriptionBackLink } from "./subscription-back-link";
|
||||||
|
import { SubscriptionBanner } from "./subscription-banner";
|
||||||
|
import { SubscriptionBenefitsCard } from "./subscription-benefits-card";
|
||||||
|
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||||
|
import { SubscriptionPlanCard } from "./subscription-plan-card";
|
||||||
|
import { SubscriptionUserRow } from "./subscription-user-row";
|
||||||
|
import styles from "./subscription-screen.module.css";
|
||||||
|
|
||||||
|
const VIP_BENEFITS = [
|
||||||
|
"Unlimited chat sessions per day",
|
||||||
|
"Unlimited free access to private messages",
|
||||||
|
"Unlimited access to AI boyfriend photos",
|
||||||
|
"Free voice chat for X minutes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const FALLBACK_USERNAME = "User name";
|
||||||
|
|
||||||
|
export function SubscriptionScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useUserState();
|
||||||
|
|
||||||
|
const initialPlanId =
|
||||||
|
SUBSCRIPTION_PLANS.find((p) => p.highlight)?.id ?? null;
|
||||||
|
|
||||||
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(
|
||||||
|
initialPlanId,
|
||||||
|
);
|
||||||
|
const [agreed, setAgreed] = useState(false);
|
||||||
|
|
||||||
|
const canActivate = selectedPlanId !== null && agreed;
|
||||||
|
|
||||||
|
const handleActivate = () => {
|
||||||
|
if (!canActivate) return;
|
||||||
|
// Mock phase: 仅提示并跳走,不调用任何支付接口
|
||||||
|
window.alert("Subscription activated (mock). Welcome to VIP!");
|
||||||
|
router.push(ROUTES.chat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
||||||
|
const avatarUrl = user.currentUser?.avatarUrl ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileShell>
|
||||||
|
<div className={styles.shell}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<SubscriptionBackLink className={styles.backSlot} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.userSlot}>
|
||||||
|
<SubscriptionUserRow name={name} avatarUrl={avatarUrl} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.bannerSlot}>
|
||||||
|
<SubscriptionBanner />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={styles.plansRow}
|
||||||
|
aria-label="Choose a subscription plan"
|
||||||
|
>
|
||||||
|
{SUBSCRIPTION_PLANS.map((plan) => (
|
||||||
|
<SubscriptionPlanCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
selected={selectedPlanId === plan.id}
|
||||||
|
onClick={() => setSelectedPlanId(plan.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className={styles.autoRenewCaption}>
|
||||||
|
It will automatically renew upon expiration, and can be canceled at
|
||||||
|
any time
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className={styles.benefitsSlot}>
|
||||||
|
<SubscriptionBenefitsCard
|
||||||
|
title="VIP Membership Benefits"
|
||||||
|
items={VIP_BENEFITS}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={styles.ctaSlot}>
|
||||||
|
<SubscriptionCtaButton
|
||||||
|
disabled={!canActivate}
|
||||||
|
onClick={handleActivate}
|
||||||
|
>
|
||||||
|
Confirm the agreement and Activate
|
||||||
|
</SubscriptionCtaButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.agreementSlot}>
|
||||||
|
<Checkbox
|
||||||
|
checked={agreed}
|
||||||
|
onChange={setAgreed}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
I agree to the{" "}
|
||||||
|
<a
|
||||||
|
href={AppConstants.privacyPolicyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={styles.agreementLink}
|
||||||
|
>
|
||||||
|
VIP Membership Benefits Agreement
|
||||||
|
</a>{" "}
|
||||||
|
and{" "}
|
||||||
|
<a
|
||||||
|
href={AppConstants.termsOfServiceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={styles.agreementLink}
|
||||||
|
>
|
||||||
|
Automatic renewal Agreement
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarIcon {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* 订阅页用户信息行
|
||||||
|
*
|
||||||
|
* 视觉规格(与设计稿对齐):
|
||||||
|
* - 56×56 粉色圆形头像
|
||||||
|
* - 头像右侧两行文本:用户名(16px/600)、副标题(12px/secondary)
|
||||||
|
* - 无头像时显示默认用户图标
|
||||||
|
*/
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
import styles from "./subscription-user-row.module.css";
|
||||||
|
|
||||||
|
export interface SubscriptionUserRowProps {
|
||||||
|
name: string;
|
||||||
|
/** null → 显示默认用户图标 */
|
||||||
|
avatarUrl: string | null;
|
||||||
|
subtitle?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionUserRow({
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
subtitle = "VIP membership not activated",
|
||||||
|
className,
|
||||||
|
}: SubscriptionUserRowProps) {
|
||||||
|
return (
|
||||||
|
<div className={[styles.row, className].filter(Boolean).join(" ")}>
|
||||||
|
<div className={styles.avatar} aria-hidden="true">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<Image
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
className={styles.avatarImg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className={styles.avatarIcon}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="8" r="4" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M4 20c0-4.418 3.582-7 8-7s8 2.582 8 7"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.text}>
|
||||||
|
<p className={styles.name}>{name}</p>
|
||||||
|
<p className={styles.subtitle}>{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SubscriptionScreen } from "@/app/subscription/components/subscription-screen";
|
||||||
|
|
||||||
|
export default function SubscriptionPage() {
|
||||||
|
return <SubscriptionScreen />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* VIP 订阅套餐常量
|
||||||
|
*
|
||||||
|
* Mock 阶段硬编码,后续可改为从 `ApiPath.paymentProducts` 拉取。
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/subscription/...
|
||||||
|
*/
|
||||||
|
export type SubscriptionPlanId = "monthly" | "quarterly" | "annual";
|
||||||
|
|
||||||
|
export interface SubscriptionPlan {
|
||||||
|
id: SubscriptionPlanId;
|
||||||
|
/** 卡片顶部名称,例如 "Monthly" */
|
||||||
|
name: string;
|
||||||
|
/** 当前价格 */
|
||||||
|
price: number;
|
||||||
|
/** 划线原价 */
|
||||||
|
originalPrice: number;
|
||||||
|
/** 每日折算价字符串,e.g. "$0.66/day" */
|
||||||
|
perDay: string;
|
||||||
|
/** 是否高亮(默认推荐:月付) */
|
||||||
|
highlight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUBSCRIPTION_PLANS: readonly SubscriptionPlan[] = [
|
||||||
|
{
|
||||||
|
id: "monthly",
|
||||||
|
name: "Monthly",
|
||||||
|
price: 19.9,
|
||||||
|
originalPrice: 24.9,
|
||||||
|
perDay: "$0.66/day",
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quarterly",
|
||||||
|
name: "Quarterly",
|
||||||
|
price: 49.9,
|
||||||
|
originalPrice: 59.9,
|
||||||
|
perDay: "$0.55/day",
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "annual",
|
||||||
|
name: "Annual",
|
||||||
|
price: 169.9,
|
||||||
|
originalPrice: 199.9,
|
||||||
|
perDay: "$0.47/day",
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
@@ -21,6 +21,7 @@ export const ROUTES = {
|
|||||||
chat: "/chat",
|
chat: "/chat",
|
||||||
auth: "/auth",
|
auth: "/auth",
|
||||||
sidebar: "/sidebar",
|
sidebar: "/sidebar",
|
||||||
|
subscription: "/subscription",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
||||||
@@ -41,6 +42,7 @@ export const AUTH_ONLY_ROUTES: readonly StaticRoute[] = [
|
|||||||
export const PROTECTED_ROUTES: readonly StaticRoute[] = [
|
export const PROTECTED_ROUTES: readonly StaticRoute[] = [
|
||||||
ROUTES.chat,
|
ROUTES.chat,
|
||||||
ROUTES.sidebar,
|
ROUTES.sidebar,
|
||||||
|
ROUTES.subscription,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 公开路由(游客态也允许访问) */
|
/** 公开路由(游客态也允许访问) */
|
||||||
|
|||||||
Reference in New Issue
Block a user