feat(sidebar): redesign sidebar UI with three user states (light theme)

Replace the dark sidebar theme with a light-themed three-state UI
matching the design references:

- guest  : pink "login" pill in user row, no status pill on VIP card,
           "Activate VIP Membership" button shown
- member : "VIP membership not activated" subtitle, no status pill,
           "Activate VIP Membership" button shown
- vip    : pink "VIP Member" pill with diamond icon, "Activated" pill
           in VIP card header, no Activate button

Decompose into BackBar, UserHeader, VipBenefitsCard, and VoicePackageCard
under src/app/sidebar/components/. Delete obsolete GuestPanel,
UserInfoCard, and VipCta.

Lift VIP_BENEFITS to a shared src/data/constants/vip-benefits.ts so the
sidebar and subscription page render identical benefit copy.

Add five sidebar tokens (--color-card-surface, --color-voice-gradient-*
--border-card, --shadow-card, --color-pill-bg) to src/tokens/colors.css.

Includes barrelsby regeneration for src/app/sidebar/components and
side-effect barrel updates under src/stores/{auth,chat,sidebar,user},
plus package-lock.json from npm install (required for lint/typecheck).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-16 10:25:04 +08:00
parent 0548a08cbb
commit e44cc7e2f3
22 changed files with 10240 additions and 559 deletions
+9461
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
.bar {
padding: var(--spacing-md, 12px) 0;
display: flex;
align-items: center;
}
.link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs, 4px);
color: #000000;
text-decoration: none;
font-size: var(--font-size-lg, 16px);
font-weight: 400;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
.link:hover {
opacity: 0.7;
}
.icon {
color: currentColor;
flex-shrink: 0;
}
.text {
color: currentColor;
font-size: var(--font-size-lg, 16px);
font-weight: 400;
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { ROUTES } from "@/router/routes";
import styles from "./back-bar.module.css";
/**
* 顶部返回栏:chevron + "Back" 文字,链接至聊天页。
*
* 视觉规格(与设计稿对齐):
* - 黑色文本(浅色主题下)
* - 居左对齐,顶部留白
*/
export function BackBar() {
return (
<header className={styles.bar}>
<Link
href={ROUTES.chat}
className={styles.link}
aria-label="Back"
>
<svg
width="20"
height="20"
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>
<span className={styles.text}>Back</span>
</Link>
</header>
);
}
@@ -1,39 +0,0 @@
"use client";
import Link from "next/link";
import { ROUTES } from "@/router/routes";
import styles from "./sidebar-screen.module.css";
/**
* 未登录态欢迎面板
*
* 显示欢迎语与 Sign in / Sign up 入口。
*/
export function GuestPanel() {
return (
<div className={styles.guestPanel}>
<h2 className={styles.guestTitle}>Welcome to CozSweet</h2>
<p className={styles.guestSubtitle}>
Sign in to sync your chats and unlock VIP.
</p>
<div className={styles.guestActions}>
<Link
href={ROUTES.auth}
className={styles.guestPrimaryLink}
aria-label="Sign up"
>
Sign up
</Link>
<Link
href={ROUTES.auth}
className={styles.guestSecondaryLink}
aria-label="Sign in"
>
Sign in
</Link>
</div>
</div>
);
}
+4 -3
View File
@@ -2,7 +2,8 @@
* @file Automatically generated by barrelsby.
*/
export * from "./guest-panel";
export * from "./back-bar";
export * from "./sidebar-screen";
export * from "./user-info-card";
export * from "./vip-cta";
export * from "./user-header";
export * from "./vip-benefits-card";
export * from "./voice-package-card";
@@ -1,302 +1,24 @@
/* ============================================================
* 侧边栏主屏幕(浅色主题)
* ============================================================ */
.shell {
display: flex;
flex-direction: column;
background: var(--color-sidebar-background, #0d0b14);
color: var(--color-text-primary, #ffffff);
background: var(--color-page-background, #ffffff);
color: var(--color-auth-text-primary, #333333);
min-height: 100dvh;
padding: var(--spacing-xxl, 24px) var(--spacing-lg, 16px);
padding: 0 var(--spacing-lg, 16px) var(--spacing-xxl, 24px);
box-sizing: border-box;
}
.backRow {
.userSlot {
padding: var(--spacing-md, 12px) 0;
}
.backLink {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs, 4px);
color: var(--color-text-primary, #ffffff);
text-decoration: none;
font-size: var(--font-size-lg, 16px);
font-weight: 400;
background: none;
border: 0;
padding: 0;
cursor: pointer;
.cardSlot {
padding: var(--spacing-sm, 8px) 0;
}
.backLink:hover {
opacity: 0.7;
}
.backIcon {
color: var(--color-text-primary, #ffffff);
flex-shrink: 0;
}
.backText {
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-lg, 16px);
font-weight: 400;
}
.settingsArea {
padding: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-lg, 16px);
}
/* 让 SettingsSection 适配深色侧边栏背景(覆盖 _components/core 里的硬编码浅色) */
.settingsArea :global(section) > h3 {
color: var(--color-text-secondary, #9e9e9e);
}
.settingsArea :global(ul) {
background: var(--color-bubble-background, #1a1625);
}
.settingsArea :global(li) {
background: transparent;
}
.settingsArea :global(button) {
color: var(--color-text-primary, #ffffff);
}
.settingsArea :global(button) span {
color: var(--color-text-primary, #ffffff);
}
.settingsArea :global(svg) {
color: var(--color-text-primary, #ffffff);
}
/* ============================================================
* 用户信息区
* ============================================================ */
.profileArea {
padding: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-lg, 16px);
width: 100%;
margin-top: var(--spacing-md, 12px);
}
.userInfoCard {
display: flex;
align-items: center;
gap: var(--spacing-md, 12px);
padding: var(--spacing-lg, 16px);
background: var(--color-bubble-background, #1a1625);
border-radius: var(--radius-xl, 16px);
color: var(--color-text-primary, #ffffff);
width: 100%;
box-sizing: border-box;
}
.userInfoAvatar {
border-radius: var(--radius-full, 999px);
object-fit: cover;
flex-shrink: 0;
}
.userInfoAvatarFallback {
width: 64px;
height: 64px;
border-radius: var(--radius-full, 999px);
background: var(--color-dark-gray, #2a2a2a);
color: var(--color-text-primary, #ffffff);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl, 20px);
font-weight: 600;
flex-shrink: 0;
}
.userInfoText {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
flex: 1 1 auto;
min-width: 0;
}
.userInfoNameRow {
display: flex;
align-items: center;
gap: var(--spacing-sm, 8px);
min-width: 0;
}
.userInfoName {
font-size: var(--font-size-lg, 16px);
font-weight: 600;
color: var(--color-text-primary, #ffffff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.userInfoEmail {
font-size: var(--font-size-sm, 12px);
color: var(--color-text-secondary, #9e9e9e);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.userInfoCoinRow {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs, 4px);
font-size: var(--font-size-sm, 12px);
color: var(--color-text-secondary, #9e9e9e);
margin-top: 2px;
}
.vipBadge {
display: inline-flex;
align-items: center;
padding: 2px var(--spacing-sm, 8px);
border-radius: var(--radius-full, 999px);
background: var(--color-accent, #f84d96);
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-xs, 10px);
font-weight: 700;
letter-spacing: 0.5px;
flex-shrink: 0;
text-transform: uppercase;
}
.vipCta {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px);
background: linear-gradient(
90deg,
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-lg, 16px);
font-weight: 600;
border: 0;
cursor: pointer;
width: 100%;
text-decoration: none;
box-sizing: border-box;
}
.vipCta:hover {
opacity: 0.9;
}
.vipCta:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 2px;
}
/* ============================================================
* 未登录态 GuestPanel
* ============================================================ */
.guestPanel {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-xl, 20px);
background: var(--color-bubble-background, #1a1625);
border-radius: var(--radius-xl, 16px);
color: var(--color-text-primary, #ffffff);
width: 100%;
box-sizing: border-box;
}
.guestTitle {
font-size: var(--font-size-xl, 18px);
font-weight: 600;
color: var(--color-text-primary, #ffffff);
margin: 0;
}
.guestSubtitle {
font-size: var(--font-size-sm, 12px);
color: var(--color-text-secondary, #9e9e9e);
margin: 0;
line-height: 1.5;
}
.guestActions {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
margin-top: var(--spacing-md, 12px);
}
.guestPrimaryLink {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px);
background: linear-gradient(
90deg,
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-lg, 16px);
font-weight: 600;
text-decoration: none;
border: 0;
box-sizing: border-box;
}
.guestPrimaryLink:hover {
opacity: 0.9;
}
.guestSecondaryLink {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px);
background: transparent;
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-lg, 16px);
font-weight: 600;
text-decoration: none;
border: 1px solid var(--color-dialog-border, rgba(255, 255, 255, 0.1));
box-sizing: border-box;
}
.guestSecondaryLink:hover {
background: var(--color-dark-gray, #2a2a2a);
}
/* ============================================================
* 骨架占位
* ============================================================ */
.skeleton {
display: block;
background: var(--color-dark-gray, #2a2a2a);
border-radius: var(--radius-md, 8px);
}
.skeletonMd {
width: 60%;
height: 18px;
}
.skeletonSm {
width: 80%;
height: 14px;
}
.skeletonXs {
width: 40%;
height: 14px;
.settingsSlot {
padding: var(--spacing-lg, 16px) 0 0;
}
+44 -63
View File
@@ -1,35 +1,35 @@
"use client";
import { useEffect, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core/mobile-shell";
import { SettingsSection } from "@/app/_components/core/settings-section";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { ROUTES } from "@/router/routes";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import type { UserView } from "@/models/user/user";
import { GuestPanel } from "./guest-panel";
import { UserInfoCard } from "./user-info-card";
import { VipCta } from "./vip-cta";
import { BackBar } from "./back-bar";
import { UserHeader, type SidebarUserState } from "./user-header";
import { VipBenefitsCard } from "./vip-benefits-card";
import { VoicePackageCard } from "./voice-package-card";
import styles from "./sidebar-screen.module.css";
const FALLBACK_USERNAME = "User name";
/**
* 侧边栏主屏幕
*
* 根据 `loginStatus + currentUser.isVip` 派生三种状态:
* - guest:未登录,展示欢迎面板
* - member:已登录未购买 VIP,展示用户信息 + Get VIP CTA
* - vip:已登录且购买 VIP,展示用户信息(含 VIP 徽章) + Manage VIP CTA
* - guest:未登录
* - member:已登录未购买 VIP
* - vip:已登录且购买 VIP
*
* 原始 Dart: lib/ui/sidebar/sidebar_screen.dart + profile_view.dart
*/
type SidebarState = "guest" | "member" | "vip";
export function SidebarScreen() {
const user = useUserState();
const userDispatch = useUserDispatch();
@@ -58,71 +58,52 @@ export function SidebarScreen() {
}, [user.currentUser, authDispatch, router]);
// 状态派生:未登录 / 已登录未 VIP / 已登录 VIP
const sidebarState: SidebarState =
const sidebarState: SidebarUserState =
auth.loginStatus === "notLoggedIn"
? "guest"
: user.currentUser?.isVip === true
? "vip"
: "member";
const isInitializingUser =
sidebarState !== "guest" && user.currentUser == null;
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.currentUser?.avatarUrl ?? null;
return (
<MobileShell>
<div className={styles.shell}>
{/* 返回按钮:与 Flutter BackButton widget 一致 */}
<div className={styles.backRow}>
<Link href={ROUTES.chat} className={styles.backLink} aria-label="Back">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={styles.backIcon}
>
<path
d="M15 6l-6 6 6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={styles.backText}>Back</span>
</Link>
</div>
<BackBar />
{/* 用户信息区:三种状态 */}
<div className={styles.profileArea}>
{sidebarState === "guest" ? (
<GuestPanel />
) : user.currentUser == null ? (
// 已登录但 currentUser 仍在初始化 → 骨架占位
<div className={styles.userInfoCard} aria-hidden="true">
<div className={styles.userInfoAvatarFallback} />
<div className={styles.userInfoText}>
<div className={`${styles.skeleton} ${styles.skeletonMd}`} />
<div className={`${styles.skeleton} ${styles.skeletonSm}`} />
<div className={`${styles.skeleton} ${styles.skeletonXs}`} />
</div>
</div>
) : (
<>
<UserInfoCard
user={user.currentUser}
coinBalance={user.coinBalance}
isVip={sidebarState === "vip"}
voiceMinutesRemaining={user.currentUser.voiceMinutesRemaining}
<section className={styles.userSlot}>
<UserHeader
state={sidebarState}
name={name}
avatarUrl={avatarUrl}
isLoading={isInitializingUser}
onLoginClick={() => router.push(ROUTES.auth)}
/>
<VipCta variant={sidebarState === "vip" ? "manage" : "get"} />
</>
)}
</div>
</section>
<section className={styles.cardSlot}>
<VipBenefitsCard
state={sidebarState}
onActivate={() => router.push(ROUTES.subscription)}
/>
</section>
<section className={styles.cardSlot}>
<VoicePackageCard
usedMinutes={0}
totalMinutes={0}
onBuyPackage={() => router.push(ROUTES.subscription)}
/>
</section>
{/* 设置区域:仅已登录态保留 Log out;与 Flutter SettingsSection 对齐 */}
{sidebarState !== "guest" ? (
<div className={styles.settingsArea}>
<section className={styles.settingsSlot}>
<SettingsSection
title="Other"
items={[
@@ -139,7 +120,7 @@ export function SidebarScreen() {
},
]}
/>
</div>
</section>
) : null}
</div>
</MobileShell>
@@ -0,0 +1,130 @@
.row {
display: flex;
align-items: center;
gap: var(--spacing-md, 12px);
width: 100%;
box-sizing: border-box;
}
.avatar {
flex: 0 0 auto;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-accent, #f84d96);
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: 4px;
flex: 1 1 auto;
min-width: 0;
}
.name {
margin: 0;
font-size: var(--font-size-lg, 16px);
font-weight: 600;
color: #000000;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
margin: 0;
font-size: var(--font-size-sm, 12px);
color: var(--color-text-secondary, #9e9e9e);
line-height: 1.2;
}
/* "VIP Member" 粉色 pill(含小钻石图标) */
.vipMemberPill {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs, 4px);
align-self: flex-start;
padding: 3px var(--spacing-sm, 8px);
border-radius: var(--radius-full, 999px);
background: var(--color-pill-bg, rgba(248, 77, 150, 0.10));
color: var(--color-accent, #f84d96);
font-size: var(--font-size-xs, 10px);
font-weight: 600;
line-height: 1;
}
.diamondIcon {
color: var(--color-accent, #f84d96);
flex-shrink: 0;
}
/* guest 状态下的 "login" 粉色胶囊按钮 */
.loginPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px);
background: linear-gradient(
90deg,
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-sm, 12px);
font-weight: 600;
border: 0;
cursor: pointer;
flex-shrink: 0;
}
.loginPill:hover {
opacity: 0.9;
}
.loginPill:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 2px;
}
/* ============================================================
* 骨架占位(isLoading 时显示)
* ============================================================ */
.skeleton {
display: block;
background: var(--color-dark-gray, #e5e5e5);
border-radius: var(--radius-md, 8px);
}
.skeletonMd {
width: 60%;
height: 18px;
}
.skeletonSm {
width: 80%;
height: 14px;
}
.skeletonXs {
width: 40%;
height: 14px;
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
import styles from "./user-header.module.css";
export type SidebarUserState = "guest" | "member" | "vip";
export interface UserHeaderProps {
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
state: SidebarUserState;
/** 用户名(无 currentUser 时使用 fallback "User name" */
name: string;
/** null → 默认头像 SVG */
avatarUrl: string | null;
/** 当前是否正在加载(currentUser 尚未就绪) */
isLoading?: boolean;
/** guest 状态下点击 "login" pill 触发 */
onLoginClick?: () => void;
}
/**
* 用户信息行
*
* - guest:仅显示 "User name" + 右侧 "login" 胶囊
* - member:显示 "User name" + 灰色副标题 "VIP membership not activated"
* - vip:显示 "User name" + 粉色 "VIP Member" 胶囊(带小钻石图标)
* - isLoading:渲染 3 条骨架占位
*/
export function UserHeader({
state,
name,
avatarUrl,
isLoading = false,
onLoginClick,
}: UserHeaderProps) {
if (isLoading) {
return (
<div className={styles.row} aria-hidden="true">
<div className={styles.avatar} />
<div className={styles.text}>
<span className={`${styles.skeleton} ${styles.skeletonMd}`} />
<span className={`${styles.skeleton} ${styles.skeletonSm}`} />
<span className={`${styles.skeleton} ${styles.skeletonXs}`} />
</div>
</div>
);
}
return (
<div className={styles.row}>
<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>
{state === "member" && (
<p className={styles.subtitle}>VIP membership not activated</p>
)}
{state === "vip" && (
<span className={styles.vipMemberPill}>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={styles.diamondIcon}
>
<path d="M12 2L22 12L12 22L2 12L12 2Z" />
</svg>
<span>VIP Member</span>
</span>
)}
</div>
{state === "guest" && (
<button
type="button"
className={styles.loginPill}
onClick={onLoginClick}
aria-label="Log in"
>
login
</button>
)}
</div>
);
}
@@ -1,109 +0,0 @@
"use client";
import Image from "next/image";
import type { UserView } from "@/models/user/user";
import styles from "./sidebar-screen.module.css";
export interface UserInfoCardProps {
user: UserView;
coinBalance: number;
isVip: boolean;
voiceMinutesRemaining: number;
}
/**
* 已登录用户信息卡片
*
* 头像 + 用户名(+ VIP 徽章)+ 邮箱 + 金币余额 + 语音剩余分钟。
*/
export function UserInfoCard({
user,
coinBalance,
isVip,
voiceMinutesRemaining,
}: UserInfoCardProps) {
const initial = (user.username?.[0] ?? "?").toUpperCase();
const hasAvatar = user.avatarUrl && user.avatarUrl.length > 0;
return (
<div className={styles.userInfoCard}>
{hasAvatar ? (
<Image
src={user.avatarUrl}
alt=""
width={64}
height={64}
className={styles.userInfoAvatar}
/>
) : (
<div
className={styles.userInfoAvatarFallback}
aria-hidden="true"
role="img"
aria-label={user.username}
>
{initial}
</div>
)}
<div className={styles.userInfoText}>
<div className={styles.userInfoNameRow}>
<span className={styles.userInfoName} title={user.username}>
{user.username}
</span>
{isVip ? (
<span className={styles.vipBadge} aria-label="VIP">
VIP
</span>
) : null}
</div>
{user.email ? (
<span className={styles.userInfoEmail} title={user.email}>
{user.email}
</span>
) : null}
<div className={styles.userInfoCoinRow}>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
<span>{coinBalance} coins</span>
</div>
{isVip ? (
<div className={styles.userInfoVoiceRow} aria-label="Voice minutes">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z M19 10v2a7 7 0 01-14 0v-2 M12 19v4 M8 23h8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>{voiceMinutesRemaining} voice min</span>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,109 @@
/* 双层 background 实现渐变描边:
* 内层白色填充到 padding-box
* 外层粉色→品红渐变填充到 border-box。 */
.card {
background:
linear-gradient(#ffffff, #ffffff) padding-box,
linear-gradient(
135deg,
var(--color-auth-primary-gradient-start, #f96ade),
var(--color-auth-primary-gradient-end, #f657a0)
) border-box;
border: var(--border-card, 1.5px) solid transparent;
border-radius: var(--radius-xl, 16px);
box-shadow: var(--shadow-card, 0 1px 3px rgba(0, 0, 0, 0.04));
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md, 12px);
padding: var(--spacing-lg, 16px) var(--spacing-lg, 16px) var(--spacing-sm, 8px);
}
.title {
margin: 0;
font-size: var(--font-size-lg, 16px);
font-weight: 700;
color: var(--color-accent, #f84d96);
line-height: 1.3;
}
.statusPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px var(--spacing-md, 12px);
border-radius: var(--radius-full, 999px);
background: var(--color-pill-bg, rgba(248, 77, 150, 0.10));
color: var(--color-accent, #f84d96);
font-size: var(--font-size-xs, 10px);
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
flex-shrink: 0;
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.item {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-sm, 8px) var(--spacing-lg, 16px);
font-size: var(--font-size-md, 14px);
color: var(--color-auth-text-primary, #333333);
line-height: 1.4;
}
.numeral {
flex: 0 0 auto;
font-weight: 500;
min-width: 20px;
color: var(--color-auth-text-primary, #333333);
}
.text {
flex: 1 1 auto;
}
.footer {
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px) var(--spacing-lg, 16px);
}
.activateBtn {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px);
background: linear-gradient(
90deg,
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-md, 14px);
font-weight: 600;
border: 0;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
.activateBtn:hover {
opacity: 0.9;
}
.activateBtn:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 2px;
}
@@ -0,0 +1,63 @@
"use client";
import { VIP_BENEFITS } from "@/data/constants/vip-benefits";
import type { SidebarUserState } from "./user-header";
import styles from "./vip-benefits-card.module.css";
export interface VipBenefitsCardProps {
state: SidebarUserState;
onActivate: () => void;
}
/**
* VIP Membership Benefits 卡片
*
* 视觉规格(与设计稿对齐):
* - 白色卡片 + 粉色→品红渐变描边
* - 标题粉色加粗,右上角条件渲染 "Activated" pill(仅 VIP
* - 1./2./3./4. 编号列表
* - 底部条件渲染 "Activate VIP Membership" 按钮(非 VIP 时显示)
*
* 状态差异:
* - guest / member → 无状态 pill + 显示 Activate 按钮
* - vip → 显示 Activated pill + 无按钮
*/
export function VipBenefitsCard({ state, onActivate }: VipBenefitsCardProps) {
const isVip = state === "vip";
return (
<section className={styles.card}>
<header className={styles.header}>
<h2 className={styles.title}>VIP Membership Benefits</h2>
{isVip && (
<span className={styles.statusPill} aria-label="Activated">
Activated
</span>
)}
</header>
<ol className={styles.list}>
{VIP_BENEFITS.map((item, idx) => (
<li key={idx} className={styles.item}>
<span className={styles.numeral}>{idx + 1}.</span>
<span className={styles.text}>{item}</span>
</li>
))}
</ol>
{!isVip && (
<div className={styles.footer}>
<button
type="button"
className={styles.activateBtn}
onClick={onActivate}
aria-label="Activate VIP Membership"
>
Activate VIP Membership
</button>
</div>
)}
</section>
);
}
-45
View File
@@ -1,45 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/router/routes";
import styles from "./sidebar-screen.module.css";
export type VipCtaVariant = "get" | "manage";
export interface VipCtaProps {
variant: VipCtaVariant;
onClick?: () => void;
}
/**
* VIP CTA 按钮
*
* - `get`:未购买 VIP 时显示 "Get VIP"
* - `manage`:已购买 VIP 时显示 "Manage VIP"
*
* 默认点击跳转 `/subscription`(订阅页);调用方可传入 `onClick` 覆盖。
*/
export function VipCta({ variant, onClick }: VipCtaProps) {
const router = useRouter();
const label = variant === "get" ? "Get VIP" : "Manage VIP";
const handleClick = () => {
if (onClick) {
onClick();
} else {
router.push(ROUTES.subscription);
}
};
return (
<button
type="button"
className={styles.vipCta}
onClick={handleClick}
aria-label={label}
>
{label}
</button>
);
}
@@ -0,0 +1,104 @@
/* 双层 background 实现 pink → orange 渐变描边 */
.card {
background:
linear-gradient(#ffffff, #ffffff) padding-box,
linear-gradient(
135deg,
var(--color-voice-gradient-start, #ff6fa3),
var(--color-voice-gradient-end, #ff9a5a)
) border-box;
border: var(--border-card, 1.5px) solid transparent;
border-radius: var(--radius-xl, 16px);
box-shadow: var(--shadow-card, 0 1px 3px rgba(0, 0, 0, 0.04));
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md, 12px);
padding: var(--spacing-lg, 16px) var(--spacing-lg, 16px) var(--spacing-sm, 8px);
}
.title {
margin: 0;
font-size: var(--font-size-lg, 16px);
font-weight: 700;
color: var(--color-accent, #f84d96);
line-height: 1.3;
}
.micIcon {
flex: 0 0 auto;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--color-voice-gradient-start, #ff6fa3),
var(--color-voice-gradient-end, #ff9a5a)
);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
box-shadow: 0 4px 12px rgba(255, 111, 163, 0.25);
}
.micGlyph {
color: inherit;
}
.usage {
margin: 0;
padding: 0 var(--spacing-lg, 16px);
font-size: var(--font-size-md, 14px);
color: var(--color-text-secondary, #9e9e9e);
line-height: 1.4;
}
.usageNumber {
color: var(--color-accent, #f84d96);
font-weight: 700;
}
.usageText {
color: var(--color-text-secondary, #9e9e9e);
font-weight: 400;
}
.footer {
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px) var(--spacing-lg, 16px);
}
.buyBtn {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px);
background: linear-gradient(
90deg,
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-md, 14px);
font-weight: 600;
border: 0;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
.buyBtn:hover {
opacity: 0.9;
}
.buyBtn:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 2px;
}
@@ -0,0 +1,72 @@
"use client";
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}>
<header className={styles.header}>
<h2 className={styles.title}>Voice Message Package</h2>
<div className={styles.micIcon} aria-hidden="true">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
className={styles.micGlyph}
>
<path
d="M12 14a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v5a3 3 0 0 0 3 3Z"
fill="currentColor"
/>
<path
d="M19 11a1 1 0 1 0-2 0 5 5 0 0 1-10 0 1 1 0 1 0-2 0 7 7 0 0 0 6 6.92V21a1 1 0 1 0 2 0v-3.08A7 7 0 0 0 19 11Z"
fill="currentColor"
/>
</svg>
</div>
</header>
<p className={styles.usage}>
<span className={styles.usageNumber}>
{usedMinutes}min/{totalMinutes}min
</span>
<span className={styles.usageText}> used/remaining</span>
</p>
<div className={styles.footer}>
<button
type="button"
className={styles.buyBtn}
onClick={onBuyPackage}
aria-label="Buy Package"
>
Buy Package
</button>
</div>
</section>
);
}
@@ -23,6 +23,7 @@ import {
SUBSCRIPTION_PLANS,
type SubscriptionPlanId,
} from "@/data/constants/subscription-plans";
import { VIP_BENEFITS } from "@/data/constants/vip-benefits";
import { useUserState } from "@/stores/user/user-context";
import { SubscriptionBackLink } from "./subscription-back-link";
@@ -34,13 +35,6 @@ 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() {
+12
View File
@@ -0,0 +1,12 @@
/**
* VIP 会员权益文案
*
* 共享常量:侧边栏 (`/sidebar`) 与订阅页 (`/subscription`) 都引用,
* 保持两处文案一致。
*/
export const VIP_BENEFITS: readonly string[] = [
"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;
+8 -1
View File
@@ -2,4 +2,11 @@
* @file Automatically generated by barrelsby.
*/
export * from "./index";
export * from "./auth-actors";
export * from "./auth-context";
export * from "./auth-events";
export * from "./auth-helpers";
export * from "./auth-machine";
export * from "./auth-state";
export * from "./auth-status-checker";
export * from "./oauth-session-sync";
+6 -1
View File
@@ -2,4 +2,9 @@
* @file Automatically generated by barrelsby.
*/
export * from "./index";
export * from "./chat-context";
export * from "./chat-events";
export * from "./chat-machine.actors";
export * from "./chat-machine.helpers";
export * from "./chat-machine";
export * from "./chat-state";
+4 -1
View File
@@ -2,4 +2,7 @@
* @file Automatically generated by barrelsby.
*/
export * from "./index";
export * from "./sidebar-context";
export * from "./sidebar-events";
export * from "./sidebar-machine";
export * from "./sidebar-state";
+4 -1
View File
@@ -2,4 +2,7 @@
* @file Automatically generated by barrelsby.
*/
export * from "./index";
export * from "./user-context";
export * from "./user-events";
export * from "./user-machine";
export * from "./user-state";
+17
View File
@@ -90,4 +90,21 @@
--color-settings-divider: #3d314a;
--color-chevron-icon: #000000;
--color-destructive: #ff6b6b;
/* ==================== Sidebar(浅色主题) ==================== */
/* 卡片纯白表面 */
--color-card-surface: #ffffff;
/* Voice Message Package 边框/图标渐变(pink → orange */
--color-voice-gradient-start: #ff6fa3;
--color-voice-gradient-end: #ff9a5a;
/* 卡片描边粗细 */
--border-card: 1.5px;
/* 卡片阴影(极轻) */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04);
/* 已激活状态 pill 背景(用于 VIP "Activated" 角标) */
--color-pill-bg: rgba(248, 77, 150, 0.10);
}