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:
Generated
+9461
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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user