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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,7 +91,34 @@ export function SidebarScreen() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 设置区域:与 Flutter SettingsSection 对齐 */}
|
||||
{/* 用户信息区:三种状态 */}
|
||||
<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"
|
||||
@@ -90,6 +137,7 @@ export function SidebarScreen() {
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user