feat(sidebar): show user info with three-state UI (guest/member/vip)

Sidebar screen now primarily displays user-related information
and derives three render states from loginStatus + currentUser.isVip:

- guest (notLoggedIn): welcome panel + Sign in / Sign up entries
- member (logged in, no VIP): user info card + Get VIP CTA + Log out
- vip (logged in, VIP): user info card with VIP badge + Manage VIP CTA
  + Log out

Adds UserView.isVip (default false), patches user-machine.toView()
to populate it, and introduces three presentational components
(GuestPanel, UserInfoCard, VipCta) co-located under src/app/sidebar.

Preserves existing UserInit + logout-redirect behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:52:52 +08:00
parent 1449acd6c9
commit fd5dfe2715
9 changed files with 466 additions and 21 deletions
@@ -0,0 +1,39 @@
"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>
);
}
+3
View File
@@ -2,4 +2,7 @@
* @file Automatically generated by barrelsby.
*/
export * from "./guest-panel";
export * from "./sidebar-screen";
export * from "./user-info-card";
export * from "./vip-cta";
@@ -47,3 +47,229 @@
align-items: flex-start;
gap: var(--spacing-lg, 16px);
}
/* ============================================================
* 用户信息区
* ============================================================ */
.profileArea {
padding: 0 var(--spacing-md, 12px);
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-lg, 16px);
width: 100%;
}
.userInfoCard {
display: flex;
align-items: center;
gap: var(--spacing-md, 12px);
padding: var(--spacing-lg, 16px);
background: var(--color-settings-card-background, #ffffff);
border-radius: var(--radius-lg, 12px);
color: #000;
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-settings-row-background, #f2f2f2);
color: #000;
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: #000;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.userInfoEmail {
font-size: var(--font-size-sm, 14px);
color: var(--color-text-secondary, #666);
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, 14px);
color: var(--color-text-secondary, #666);
}
.vipBadge {
display: inline-flex;
align-items: center;
padding: 2px var(--spacing-sm, 8px);
border-radius: var(--radius-full, 999px);
background: var(--color-accent, #7c5cff);
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-xs, 12px);
font-weight: 600;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.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, #7c5cff),
var(--color-button-gradient-end, #b48bff)
);
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;
}
.vipCta:hover {
opacity: 0.9;
}
.vipCta:focus-visible {
outline: 2px solid var(--color-accent, #7c5cff);
outline-offset: 2px;
}
/* ============================================================
* 未登录态 GuestPanel
* ============================================================ */
.guestPanel {
display: flex;
flex-direction: column;
gap: var(--spacing-md, 12px);
padding: var(--spacing-lg, 16px);
background: var(--color-settings-card-background, #ffffff);
border-radius: var(--radius-lg, 12px);
color: #000;
width: 100%;
box-sizing: border-box;
}
.guestTitle {
font-size: var(--font-size-xl, 20px);
font-weight: 600;
color: #000;
margin: 0;
}
.guestSubtitle {
font-size: var(--font-size-sm, 14px);
color: var(--color-text-secondary, #666);
margin: 0;
}
.guestActions {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
margin-top: var(--spacing-sm, 8px);
}
.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, #7c5cff),
var(--color-button-gradient-end, #b48bff)
);
color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-lg, 16px);
font-weight: 600;
text-decoration: none;
border: 0;
}
.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: #000;
font-size: var(--font-size-lg, 16px);
font-weight: 600;
text-decoration: none;
border: 1px solid var(--color-auth-border, #d0d0d0);
}
.guestSecondaryLink:hover {
background: var(--color-settings-row-background, #f2f2f2);
}
/* ============================================================
* 骨架占位
* ============================================================ */
.skeleton {
display: block;
background: var(--color-settings-row-background, #e5e5e5);
border-radius: var(--radius-md, 8px);
}
.skeletonMd {
width: 60%;
height: 18px;
}
.skeletonSm {
width: 80%;
height: 14px;
}
.skeletonXs {
width: 40%;
height: 14px;
}
+67 -19
View File
@@ -7,21 +7,33 @@ 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 } from "@/stores/auth/auth-context";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { ROUTES } from "@/router/routes";
import type { UserView } from "@/models/user/user";
import { GuestPanel } from "./guest-panel";
import { UserInfoCard } from "./user-info-card";
import { VipCta } from "./vip-cta";
import styles from "./sidebar-screen.module.css";
/**
* 侧边栏主屏幕
*
* 根据 `loginStatus + currentUser.isVip` 派生三种状态:
* - guest:未登录,展示欢迎面板
* - member:已登录未购买 VIP,展示用户信息 + Get VIP CTA
* - vip:已登录且购买 VIP,展示用户信息(含 VIP 徽章) + Manage VIP CTA
*
* 原始 Dart: lib/ui/sidebar/sidebar_screen.dart + profile_view.dart
*/
type SidebarState = "guest" | "member" | "vip";
export function SidebarScreen() {
const user = useUserState();
const userDispatch = useUserDispatch();
const auth = useAuthState();
const authDispatch = useAuthDispatch();
const router = useRouter();
@@ -45,6 +57,14 @@ export function SidebarScreen() {
}
}, [user.currentUser, authDispatch, router]);
// 状态派生:未登录 / 已登录未 VIP / 已登录 VIP
const sidebarState: SidebarState =
auth.loginStatus === "notLoggedIn"
? "guest"
: user.currentUser?.isVip === true
? "vip"
: "member";
return (
<MobileShell>
<div className={styles.shell}>
@@ -71,25 +91,53 @@ export function SidebarScreen() {
</Link>
</div>
{/* 设置区域:与 Flutter SettingsSection 对齐 */}
<div className={styles.settingsArea}>
<SettingsSection
title="Other"
items={[
{
id: "membership",
title: "Membership",
onClick: () => router.push(ROUTES.subscription),
},
{
id: "logout",
title: "Log out",
destructive: true,
onClick: () => userDispatch({ type: "UserLogout" }),
},
]}
/>
{/* 用户信息区:三种状态 */}
<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"}
/>
<VipCta variant={sidebarState === "vip" ? "manage" : "get"} />
</>
)}
</div>
{/* 设置区域:仅已登录态保留 Log out;与 Flutter SettingsSection 对齐 */}
{sidebarState !== "guest" ? (
<div className={styles.settingsArea}>
<SettingsSection
title="Other"
items={[
{
id: "membership",
title: "Membership",
onClick: () => router.push(ROUTES.subscription),
},
{
id: "logout",
title: "Log out",
destructive: true,
onClick: () => userDispatch({ type: "UserLogout" }),
},
]}
/>
</div>
) : null}
</div>
</MobileShell>
);
@@ -0,0 +1,81 @@
"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;
}
/**
* 已登录用户信息卡片
*
* 头像 + 用户名(+ VIP 徽章)+ 邮箱 + 金币余额。
*/
export function UserInfoCard({ user, coinBalance, isVip }: 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>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
"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>
);
}
+2 -2
View File
@@ -22,7 +22,7 @@ import { GuestChatQuota as GuestChatQuotaDto } from "@/data/dto/chat/guest_chat_
import { z } from "zod";
import { type Result as ResultT, Result } from "@/utils/result";
import { formatDate } from "@/utils/date";
import { todayString } from "@/utils/date";
import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys";
import type { IChatStorage } from "./ichat_storage";
@@ -51,7 +51,7 @@ export class ChatStorage implements IChatStorage {
if (existing.success && existing.data != null) {
return existing;
}
const today = formatDate(new Date());
const today = todayString(new Date());
const initResult = await this.setGuestDailyChatQuota(
GuestChatQuotaDto.threshold,
today,
+1
View File
@@ -15,6 +15,7 @@ export const UserViewSchema = z.object({
relationshipStage: z.string(),
currentMood: z.string(),
isGuest: z.boolean(),
isVip: z.boolean(),
});
export type UserView = z.infer<typeof UserViewSchema>;
+2
View File
@@ -49,6 +49,7 @@ function toView(u: {
relationshipStage?: string;
currentMood?: string;
isGuest?: boolean;
isVip?: boolean;
}): UserView {
return {
id: u.id,
@@ -60,6 +61,7 @@ function toView(u: {
relationshipStage: u.relationshipStage ?? "",
currentMood: u.currentMood ?? "",
isGuest: u.isGuest ?? false,
isVip: u.isVip ?? false,
};
}