feat(profile)!: replace sidebar and add avatar navigation

Rename the global Sidebar route, UI, assets, analytics, and payment return context to Profile. Add accessible message-avatar navigation and preserve the source character across auth and logout flows.

BREAKING CHANGE: /sidebar has been removed; use /profile instead.
This commit is contained in:
2026-07-21 18:08:12 +08:00
parent 6cd3a0c2d2
commit 9deb320cf6
67 changed files with 565 additions and 256 deletions
@@ -79,4 +79,34 @@ describe("shared Tailwind components", () => {
expect(guestHtml).toContain('aria-label="Guest avatar"');
expect(guestHtml).toContain("%2Fimages%2Favatar%2Fplaceholder.png");
});
it("renders interactive avatars as accessible buttons", () => {
const characterHtml = renderToStaticMarkup(
<CharacterAvatar
src="/images/avatar/elio.png"
alt="Elio Silvestri"
actionLabel="Open Elio's private room"
analyticsKey="chat.open_private_room_from_avatar"
onClick={() => undefined}
/>,
);
const userHtml = renderToStaticMarkup(
<UserMessageAvatar
actionLabel="Open profile"
analyticsKey="chat.open_profile_from_avatar"
onClick={() => undefined}
/>,
);
expect(characterHtml).toContain('<button type="button"');
expect(characterHtml).toContain(
'aria-label="Open Elio&#x27;s private room"',
);
expect(characterHtml).toContain(
'data-analytics-key="chat.open_private_room_from_avatar"',
);
expect(userHtml).toContain('<button type="button"');
expect(userHtml).toContain('aria-label="Open profile"');
expect(userHtml).toContain("focus-visible:outline-3");
});
});
+11
View File
@@ -0,0 +1,11 @@
export type AvatarInteractionProps =
| {
onClick?: undefined;
actionLabel?: never;
analyticsKey?: never;
}
| {
onClick: () => void;
actionLabel: string;
analyticsKey?: string;
};
+47 -18
View File
@@ -3,7 +3,9 @@
import Image from "next/image";
import type { CSSProperties } from "react";
export interface CharacterAvatarProps {
import type { AvatarInteractionProps } from "./avatar-interaction";
interface CharacterAvatarVisualProps {
src: string;
alt: string;
className?: string;
@@ -12,6 +14,9 @@ export interface CharacterAvatarProps {
priority?: boolean;
}
export type CharacterAvatarProps = CharacterAvatarVisualProps &
AvatarInteractionProps;
export function CharacterAvatar({
src,
alt,
@@ -19,6 +24,9 @@ export function CharacterAvatar({
size = 43,
imageSize,
priority = false,
onClick,
actionLabel,
analyticsKey,
}: CharacterAvatarProps) {
const resolvedImageSize =
imageSize ?? (typeof size === "number" ? size : 96);
@@ -27,24 +35,45 @@ export function CharacterAvatar({
height: size,
};
const avatarClassName = [
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border-0 bg-(--color-avatar-border,#fbf3f5)",
onClick
? "cursor-pointer p-0 transition-transform duration-150 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-accent active:scale-96"
: undefined,
className,
]
.filter(Boolean)
.join(" ");
const image = (
<Image
src={src}
alt={alt}
width={resolvedImageSize}
height={resolvedImageSize}
priority={priority}
className="size-full object-cover"
/>
);
if (onClick) {
return (
<button
type="button"
className={avatarClassName}
style={style}
aria-label={actionLabel}
data-analytics-key={analyticsKey}
data-analytics-label={actionLabel}
onClick={onClick}
>
{image}
</button>
);
}
return (
<span
className={[
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--color-avatar-border,#fbf3f5)",
className,
]
.filter(Boolean)
.join(" ")}
style={style}
>
<Image
src={src}
alt={alt}
width={resolvedImageSize}
height={resolvedImageSize}
priority={priority}
className="size-full object-cover"
/>
<span className={avatarClassName} style={style}>
{image}
</span>
);
}
+1
View File
@@ -3,5 +3,6 @@
*/
export * from "./back-button";
export * from "./avatar-interaction";
export * from "./character-avatar";
export * from "./user-message-avatar";
+34 -20
View File
@@ -2,19 +2,30 @@
import Image from "next/image";
export interface UserMessageAvatarProps {
import type { AvatarInteractionProps } from "./avatar-interaction";
interface UserMessageAvatarVisualProps {
avatarUrl?: string | null;
className?: string;
size?: number | string;
}
export type UserMessageAvatarProps = UserMessageAvatarVisualProps &
AvatarInteractionProps;
export function UserMessageAvatar({
avatarUrl,
className,
size = 43,
onClick,
actionLabel,
analyticsKey,
}: UserMessageAvatarProps) {
const avatarClassName = [
"flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))",
onClick
? "cursor-pointer p-0 transition-transform duration-150 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-accent active:scale-96"
: undefined,
className,
]
.filter(Boolean)
@@ -22,21 +33,30 @@ export function UserMessageAvatar({
const avatarStyle = { width: size, height: size };
const imageSize = typeof size === "number" ? size : 64;
if (avatarUrl && avatarUrl.length > 0) {
const hasUserAvatar = Boolean(avatarUrl && avatarUrl.length > 0);
const image = (
<Image
src={hasUserAvatar ? avatarUrl! : "/images/avatar/placeholder.png"}
alt={hasUserAvatar ? "" : "Guest"}
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
);
if (onClick) {
return (
<div
<button
type="button"
className={avatarClassName}
style={avatarStyle}
aria-label="User avatar"
aria-label={actionLabel}
data-analytics-key={analyticsKey}
data-analytics-label={actionLabel}
onClick={onClick}
>
<Image
src={avatarUrl}
alt=""
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
</div>
{image}
</button>
);
}
@@ -44,15 +64,9 @@ export function UserMessageAvatar({
<div
className={avatarClassName}
style={avatarStyle}
aria-label="Guest avatar"
aria-label={hasUserAvatar ? "User avatar" : "Guest avatar"}
>
<Image
src="/images/avatar/placeholder.png"
alt="Guest"
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
{image}
</div>
);
}
+22 -1
View File
@@ -15,7 +15,9 @@ import {
} from "@/providers/character-provider";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
import { getCharacterRoutes } from "@/router/routes";
import { buildGlobalPageUrl } from "@/router/global-route-context";
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
import { MobileShell } from "@/app/_components/core";
@@ -57,6 +59,10 @@ export function ChatScreen() {
const refreshCharacterCatalog = characterCatalog.refresh;
const defaultCharacterSlug = characterCatalog.defaultCharacter.slug;
const characterRoutes = useActiveCharacterRoutes();
const profileUrl = buildGlobalPageUrl(
ROUTES.profile,
characterRoutes.chat,
);
const searchParams = useSearchParams();
const state = useChatState();
const chatDispatch = useChatDispatch();
@@ -234,6 +240,19 @@ export function ChatScreen() {
});
}
function handleOpenUserProfile(): void {
router.push(
resolveAuthenticatedNavigation({
loginStatus: authState.loginStatus,
targetUrl: profileUrl,
}),
);
}
function handleOpenCharacterPrivateRoom(): void {
router.push(characterRoutes.privateRoom);
}
return (
<MobileShell>
<div
@@ -280,6 +299,8 @@ export function ChatScreen() {
onUnlockVoiceMessage={handleUnlockVoiceMessage}
onUnlockImageMessage={handleUnlockImageMessage}
onOpenImage={handleOpenImage}
onUserAvatarClick={handleOpenUserProfile}
onCharacterAvatarClick={handleOpenCharacterPrivateRoom}
onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}}
@@ -31,7 +31,6 @@ describe("chat Tailwind components", () => {
<MessageAvatar isFromAI={false} userAvatarUrl="/user-avatar.png" />,
);
expect(aiHtml).toContain('aria-label="AI avatar"');
expect(aiHtml).toContain("size-(--chat-avatar-size,43px)");
expect(aiHtml).toContain("size-full object-cover");
expect(aiHtml).toContain("%2Fimages%2Favatar%2Felio.png");
@@ -39,6 +38,28 @@ describe("chat Tailwind components", () => {
expect(userHtml).toContain("%2Fuser-avatar.png");
});
it("renders message avatar actions with explicit destinations", () => {
const aiHtml = renderWithCharacter(
<MessageAvatar isFromAI={true} onClick={() => undefined} />,
);
const userHtml = renderWithCharacter(
<MessageAvatar isFromAI={false} onClick={() => undefined} />,
);
expect(aiHtml).toContain('<button type="button"');
expect(aiHtml).toContain(
'data-analytics-key="chat.open_private_room_from_avatar"',
);
expect(aiHtml).toContain(
'aria-label="Open Elio Silvestri&#x27;s private room"',
);
expect(userHtml).toContain('<button type="button"');
expect(userHtml).toContain(
'data-analytics-key="chat.open_profile_from_avatar"',
);
expect(userHtml).toContain('aria-label="Open profile"');
});
it("renders the active character avatar", () => {
const maya = getCharacterBySlug("maya");
expect(maya).not.toBeNull();
@@ -196,12 +217,12 @@ describe("chat Tailwind components", () => {
expect(guestHtml).toContain('aria-label="Back to home"');
expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(guestHtml).toContain("px-(--chat-inline-padding,16px)");
expect(guestHtml).not.toContain('aria-label="Menu"');
expect(guestHtml).not.toContain('aria-label="Profile"');
expect(memberHtml).toContain("Offer");
expect(memberHtml).toContain('href="/characters/elio/splash"');
expect(memberHtml).toContain('aria-label="Back to home"');
expect(memberHtml).toContain('aria-label="Menu"');
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
expect(memberHtml).toContain('aria-label="Profile"');
expect(memberHtml).toContain('data-analytics-key="chat.open_profile"');
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(memberHtml).toContain("px-(--chat-inline-padding,16px)");
expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
@@ -222,7 +243,7 @@ describe("chat Tailwind components", () => {
memberWithHintHtml.indexOf(
'data-analytics-key="chat.external_browser_hint"',
),
).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Menu"'));
).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Profile"'));
expect(guestWithHintHtml).not.toContain(
'data-analytics-key="chat.external_browser_hint"',
);
@@ -239,7 +260,7 @@ describe("chat Tailwind components", () => {
);
expect(html).toContain('href="/characters/maya/splash"');
expect(html).not.toContain('aria-label="Menu"');
expect(html).not.toContain('aria-label="Profile"');
expect(html).not.toContain(
'data-analytics-key="chat.external_browser_hint"',
);
+10
View File
@@ -59,6 +59,8 @@ export interface ChatAreaProps {
onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void;
onLoadMoreHistory?: () => void;
onUserAvatarClick?: () => void;
onCharacterAvatarClick?: () => void;
}
type ChatMessageAction = (
@@ -81,6 +83,8 @@ export function ChatArea({
onUnlockImageMessage,
onOpenImage,
onLoadMoreHistory,
onUserAvatarClick,
onCharacterAvatarClick,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -251,6 +255,8 @@ export function ChatArea({
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
)}
{isReplyingAI ? (
@@ -287,6 +293,8 @@ function renderMessagesWithDateHeaders(
onUnlockVoiceMessage?: ChatMessageAction,
onUnlockImageMessage?: ChatMessageAction,
onOpenImage?: (displayMessageId: string) => void,
onUserAvatarClick?: () => void,
onCharacterAvatarClick?: () => void,
) {
return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? (
@@ -314,6 +322,8 @@ function renderMessagesWithDateHeaders(
onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage}
onUserAvatarClick={onUserAvatarClick}
onCharacterAvatarClick={onCharacterAvatarClick}
/>
),
);
+7 -7
View File
@@ -2,11 +2,11 @@
/**
* ChatHeader 顶部栏
*
* 图标:lucide-react <Lock />(游客 banner+ <Menu />(菜单按钮
* 图标:lucide-react <Lock />(游客 banner+ <UserRound />Profile
* tree-shakablecurrentColor 继承父 color
*/
import type { ReactNode } from "react";
import { Lock, Menu } from "lucide-react";
import { Lock, UserRound } from "lucide-react";
import { BackButton } from "@/app/_components";
import { useActiveCharacterRoutes } from "@/providers/character-provider";
@@ -74,17 +74,17 @@ export function ChatHeader({
<button
type="button"
data-analytics-key="chat.open_menu"
data-analytics-label="Open chat menu"
data-analytics-key="chat.open_profile"
data-analytics-label="Open profile"
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={() =>
navigator.push(
buildGlobalPageUrl(ROUTES.sidebar, characterRoutes.chat),
buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat),
)
}
aria-label="Menu"
aria-label="Profile"
>
<Menu size={24} aria-hidden="true" />
<UserRound size={24} aria-hidden="true" />
</button>
</>
) : null}
+40 -11
View File
@@ -1,32 +1,61 @@
"use client";
import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components";
import { CharacterAvatar, UserMessageAvatar } from "@/app/_components";
import { useActiveCharacter } from "@/providers/character-provider";
export interface MessageAvatarProps {
isFromAI: boolean;
userAvatarUrl?: string | null;
onClick?: () => void;
}
const AVATAR_CLASS_NAME =
"flex size-(--chat-avatar-size,43px) shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
"size-(--chat-avatar-size,43px) ring-2 ring-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
export function MessageAvatar({
isFromAI,
userAvatarUrl,
onClick,
}: MessageAvatarProps) {
const character = useActiveCharacter();
if (isFromAI) {
return (
<div className={AVATAR_CLASS_NAME} aria-label="AI avatar">
<Image
if (onClick) {
return (
<CharacterAvatar
src={character.assets.avatar}
alt={character.displayName}
width={43}
height={43}
size="var(--chat-avatar-size, 43px)"
imageSize={43}
priority
className="size-full object-cover"
className={AVATAR_CLASS_NAME}
actionLabel={`Open ${character.displayName}'s private room`}
analyticsKey="chat.open_private_room_from_avatar"
onClick={onClick}
/>
</div>
);
}
return (
<CharacterAvatar
src={character.assets.avatar}
alt={character.displayName}
size="var(--chat-avatar-size, 43px)"
imageSize={43}
priority
className={AVATAR_CLASS_NAME}
/>
);
}
if (onClick) {
return (
<UserMessageAvatar
avatarUrl={userAvatarUrl}
actionLabel="Open profile"
analyticsKey="chat.open_profile_from_avatar"
onClick={onClick}
/>
);
}
+13 -2
View File
@@ -32,6 +32,8 @@ export interface MessageBubbleProps {
onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void;
onUserAvatarClick?: () => void;
onCharacterAvatarClick?: () => void;
}
type ChatMessageAction = (
@@ -57,6 +59,8 @@ export function MessageBubble({
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
}: MessageBubbleProps) {
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
@@ -67,7 +71,10 @@ export function MessageBubble({
data-chat-message-id={displayMessageId}
aria-label="AI message"
>
<MessageAvatar isFromAI={true} />
<MessageAvatar
isFromAI={true}
onClick={onCharacterAvatarClick}
/>
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent
characterId={characterId}
@@ -120,7 +127,11 @@ export function MessageBubble({
onOpenImage={onOpenImage}
/>
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
<MessageAvatar
isFromAI={false}
userAvatarUrl={avatarUrl}
onClick={onUserAvatarClick}
/>
</div>
);
}
@@ -22,14 +22,14 @@ vi.mock("@/stores/user/user-context", () => ({
}));
describe("CoinsRulesScreen", () => {
it("renders outside CharacterProvider and returns to global sidebar", () => {
it("renders outside CharacterProvider and returns to global profile", () => {
const html = renderToStaticMarkup(
<CoinsRulesScreen returnTo="/characters/maya/chat" />,
);
expect(html).toContain("Coin Usage Rules");
expect(html).toContain(
'href="/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
);
});
});
+2 -2
View File
@@ -118,9 +118,9 @@ export function CoinsRulesScreen({ returnTo }: CoinsRulesScreenProps) {
<main className={styles.screen}>
<header className={styles.header}>
<BackButton
href={navigation.sidebarUrl}
href={navigation.profileUrl}
variant="soft"
aria-label="Back to sidebar"
aria-label="Back to profile"
/>
<div className={styles.hero}>
@@ -69,9 +69,9 @@ describe("FeedbackScreen", () => {
).toBe("feedback-content-hint");
expect(
container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="feedback.back_to_sidebar"]',
'[data-analytics-key="feedback.back_to_profile"]',
)?.getAttribute("href"),
).toBe("/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat");
).toBe("/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat");
});
it("submits valid text and renders the feedback id", async () => {
@@ -106,7 +106,7 @@ describe("FeedbackScreen", () => {
});
expect(container.textContent).toContain("feedback-123");
expect(container.textContent).toContain("Thank you for helping us.");
expect(container.textContent).toContain("Back to Sidebar");
expect(container.textContent).toContain("Back to Profile");
});
});
+6 -6
View File
@@ -77,11 +77,11 @@ export function FeedbackScreen({ returnTo }: FeedbackScreenProps) {
</div>
<button
type="button"
data-analytics-key="feedback.back_to_sidebar"
data-analytics-key="feedback.back_to_profile"
className={styles.primaryButton}
onClick={() => navigator.replace(navigation.sidebarUrl)}
onClick={() => navigator.replace(navigation.profileUrl)}
>
Back to Sidebar
Back to Profile
</button>
</section>
</main>
@@ -102,10 +102,10 @@ export function FeedbackScreen({ returnTo }: FeedbackScreenProps) {
<header className={styles.header}>
<BackButton
href={navigation.sidebarUrl}
href={navigation.profileUrl}
variant="soft"
ariaLabel="Back to sidebar"
analyticsKey="feedback.back_to_sidebar"
ariaLabel="Back to profile"
analyticsKey="feedback.back_to_profile"
/>
<div className={styles.headingBlock}>
<h1 className={styles.title}>Help us improve CozSweet</h1>
@@ -4,7 +4,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarScreen } from "../sidebar-screen";
import { ProfileScreen } from "../profile-screen";
const mocks = vi.hoisted(() => ({
openAuth: vi.fn(),
@@ -59,11 +59,11 @@ vi.mock("../use-pwa-install-entry", () => ({
installApp: vi.fn(),
}),
}));
vi.mock("../use-sidebar-user-bootstrap", () => ({
useSidebarUserBootstrap: vi.fn(),
vi.mock("../use-profile-user-bootstrap", () => ({
useProfileUserBootstrap: vi.fn(),
}));
describe("SidebarScreen", () => {
describe("ProfileScreen", () => {
let container: HTMLDivElement;
let root: Root;
@@ -83,12 +83,12 @@ describe("SidebarScreen", () => {
it("renders without CharacterProvider and preserves the source character", () => {
act(() => {
root.render(<SidebarScreen returnTo="/characters/maya/chat" />);
root.render(<ProfileScreen returnTo="/characters/maya/chat" />);
});
expect(
container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="sidebar.back_to_chat"]',
'[data-analytics-key="profile.back_to_chat"]',
)?.getAttribute("href"),
).toBe("/characters/maya/chat");
@@ -107,7 +107,7 @@ describe("SidebarScreen", () => {
expect.objectContaining({
type: "topup",
sourceCharacterSlug: "maya",
returnTo: "sidebar",
returnTo: "profile",
}),
);
});
@@ -3,10 +3,10 @@ import { describe, expect, it } from "vitest";
import type { UserView } from "@/stores/user/user-view";
import {
getSidebarViewModel,
getSidebarWalletView,
normalizeSidebarCount,
} from "../sidebar-view-model";
getProfileViewModel,
getProfileWalletView,
normalizeProfileCount,
} from "../profile-view-model";
const baseUser: UserView = {
id: "user-1",
@@ -20,9 +20,9 @@ const baseUser: UserView = {
isVip: false,
};
describe("getSidebarViewModel", () => {
describe("getProfileViewModel", () => {
it("derives guest state for logged-out users", () => {
const view = getSidebarViewModel({
const view = getProfileViewModel({
loginStatus: "notLoggedIn",
user: {
currentUser: null,
@@ -37,7 +37,7 @@ describe("getSidebarViewModel", () => {
});
it("derives member state and shows VIP activation for non-VIP users", () => {
const view = getSidebarViewModel({
const view = getProfileViewModel({
loginStatus: "email",
user: {
currentUser: baseUser,
@@ -54,7 +54,7 @@ describe("getSidebarViewModel", () => {
});
it("derives VIP state and hides VIP activation for VIP users", () => {
const view = getSidebarViewModel({
const view = getProfileViewModel({
loginStatus: "email",
user: {
currentUser: { ...baseUser, isVip: true },
@@ -67,8 +67,8 @@ describe("getSidebarViewModel", () => {
expect(view.canActivateVip).toBe(false);
});
it("uses fallback name and marks logged-in users without profile as initializing", () => {
const view = getSidebarViewModel({
it("keeps Guest users in the signed-out profile state", () => {
const view = getProfileViewModel({
loginStatus: "guest",
user: {
currentUser: null,
@@ -77,15 +77,15 @@ describe("getSidebarViewModel", () => {
},
});
expect(view.state).toBe("member");
expect(view.state).toBe("guest");
expect(view.name).toBe("User name");
expect(view.isInitializingUser).toBe(true);
expect(view.isInitializingUser).toBe(false);
});
});
describe("getSidebarWalletView", () => {
describe("getProfileWalletView", () => {
it("normalizes missing, negative, and fractional wallet values", () => {
const view = getSidebarWalletView({
const view = getProfileWalletView({
currentUser: {
...baseUser,
dailyFreeChatLimit: -1,
@@ -107,7 +107,7 @@ describe("getSidebarWalletView", () => {
});
it("normalizes undefined and null counts to zero", () => {
expect(normalizeSidebarCount(undefined)).toBe(0);
expect(normalizeSidebarCount(null)).toBe(0);
expect(normalizeProfileCount(undefined)).toBe(0);
expect(normalizeProfileCount(null)).toBe(0);
});
});
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { UserHeader } from "../user-header";
describe("sidebar Tailwind components", () => {
describe("profile Tailwind components", () => {
it("renders guest, member, and VIP header states", () => {
const guestHtml = renderToStaticMarkup(
<UserHeader
@@ -2,5 +2,5 @@
* @file Automatically generated by barrelsby.
*/
export * from "./sidebar-wallet-card";
export * from "./profile-wallet-card";
export * from "./user-header";
@@ -2,9 +2,9 @@
import { FaCoins } from "react-icons/fa6";
import styles from "./sidebar-wallet-card.module.css";
import styles from "./profile-wallet-card.module.css";
export interface SidebarWalletCardProps {
export interface ProfileWalletCardProps {
creditBalance: number;
dailyFreeChatLimit: number;
dailyFreeChatRemaining: number;
@@ -18,7 +18,7 @@ export interface SidebarWalletCardProps {
const formatCoins = (value: number): string =>
new Intl.NumberFormat("en-US").format(Math.max(0, Math.trunc(value)));
export function SidebarWalletCard({
export function ProfileWalletCard({
creditBalance,
dailyFreeChatLimit,
dailyFreeChatRemaining,
@@ -27,7 +27,7 @@ export function SidebarWalletCard({
onActivateVip,
onTopUp,
onRulesClick,
}: SidebarWalletCardProps) {
}: ProfileWalletCardProps) {
return (
<section className={styles.card} aria-label="My wallet">
<div className={styles.leftColumn}>
@@ -45,7 +45,7 @@ export function SidebarWalletCard({
<button
type="button"
data-analytics-key="sidebar.coins_rules"
data-analytics-key="profile.coins_rules"
data-analytics-label="Open coin usage rules"
className={styles.rulesButton}
onClick={onRulesClick}
@@ -58,7 +58,7 @@ export function SidebarWalletCard({
{onActivateVip ? (
<button
type="button"
data-analytics-key="sidebar.activate_vip"
data-analytics-key="profile.activate_vip"
data-analytics-label="Activate VIP"
className={styles.activateButton}
onClick={onActivateVip}
@@ -68,7 +68,7 @@ export function SidebarWalletCard({
) : null}
<button
type="button"
data-analytics-key="sidebar.topup"
data-analytics-key="profile.topup"
data-analytics-label="Top up credits"
className={styles.topUpButton}
onClick={onTopUp}
@@ -3,11 +3,11 @@
import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components";
import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model";
import type { ProfileUserState } from "@/app/profile/profile-view-model";
export interface UserHeaderProps {
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
state: SidebarUserState;
state: ProfileUserState;
/** 用户名(无 currentUser 时使用 fallback "User name" */
name: string;
/** null → 默认头像 SVG */
@@ -36,8 +36,8 @@ export function UserHeader({
if (isLoading) {
return (
<div className="flex w-full items-center gap-(--spacing-md,12px)" aria-hidden="true">
<div className="flex size-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white" />
<div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)">
<div className="flex size-(--profile-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white" />
<div className="flex min-w-0 flex-auto flex-col gap-(--profile-card-gap-y,4px)">
<span className="block h-[clamp(16px,3.333vw,18px)] w-[60%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
<span className="block h-[clamp(12px,2.593vw,14px)] w-[80%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
<span className="block h-[clamp(12px,2.593vw,14px)] w-[40%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
@@ -50,11 +50,11 @@ export function UserHeader({
<div className="flex w-full items-center gap-(--spacing-md,12px)">
<UserMessageAvatar
avatarUrl={avatarUrl}
className="flex size-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white"
size="var(--sidebar-avatar-size, 56px)"
className="flex size-(--profile-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white"
size="var(--profile-avatar-size, 56px)"
/>
<div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)">
<div className="flex min-w-0 flex-auto flex-col gap-(--profile-card-gap-y,4px)">
<p className="m-0 overflow-hidden text-ellipsis whitespace-nowrap text-(length:--font-responsive-md,16px) font-semibold leading-[1.2] text-black">
{name}
</p>
@@ -66,7 +66,7 @@ export function UserHeader({
{state === "vip" && (
<span className="inline-flex self-start items-center gap-(--spacing-xs,4px) rounded-full bg-(--color-pill-bg,rgba(248,77,150,0.10)) px-[clamp(7px,1.667vw,9px)] py-[clamp(3px,0.741vw,4px)] text-(length:--font-size-xs,10px) font-semibold leading-none text-accent">
<Image
src="/images/sidebar/ic_user_vip.png"
src="/images/profile/ic_user_vip.png"
alt=""
width={39}
height={36}
@@ -80,7 +80,7 @@ export function UserHeader({
{state === "guest" && (
<button
type="button"
data-analytics-key="sidebar.login"
data-analytics-key="profile.login"
data-analytics-label="Open login"
className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[linear-gradient(90deg,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] px-(--spacing-lg,16px) py-[clamp(5px,1.111vw,6px)] text-(length:clamp(12px,2.593vw,14px)) font-semibold text-(--color-text-primary,#ffffff) hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
onClick={onLoginClick}
@@ -1,12 +1,12 @@
import type { RouteSearchParams } from "@/router/routes";
import { SidebarScreen } from "./sidebar-screen";
import { ProfileScreen } from "./profile-screen";
export default async function SidebarPage({
export default async function ProfilePage({
searchParams,
}: {
searchParams: Promise<RouteSearchParams>;
}) {
const query = await searchParams;
return <SidebarScreen returnTo={query.returnTo} />;
return <ProfileScreen returnTo={query.returnTo} />;
}
@@ -9,22 +9,23 @@ import {
resolveGlobalRouteContext,
type GlobalReturnToValue,
} from "@/router/global-route-context";
import { setPendingLogoutNavigation } from "@/router/logout-navigation";
import { useGlobalAppNavigator } from "@/router/use-global-app-navigator";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import { SidebarWalletCard, UserHeader } from "./components";
import { getSidebarViewModel } from "./sidebar-view-model";
import { ProfileWalletCard, UserHeader } from "./components";
import { getProfileViewModel } from "./profile-view-model";
import { usePwaInstallEntry } from "./use-pwa-install-entry";
import { useSidebarUserBootstrap } from "./use-sidebar-user-bootstrap";
import { useProfileUserBootstrap } from "./use-profile-user-bootstrap";
import styles from "./components/sidebar-screen.module.css";
import styles from "./components/profile-screen.module.css";
export interface SidebarScreenProps {
export interface ProfileScreenProps {
returnTo?: GlobalReturnToValue;
}
export function SidebarScreen({ returnTo }: SidebarScreenProps) {
export function ProfileScreen({ returnTo }: ProfileScreenProps) {
const navigator = useGlobalAppNavigator();
const navigation = resolveGlobalRouteContext(returnTo);
const user = useUserState();
@@ -33,16 +34,17 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
const authDispatch = useAuthDispatch();
const pwaInstallEntry = usePwaInstallEntry();
useSidebarUserBootstrap({ auth, userDispatch });
useProfileUserBootstrap({ auth, userDispatch });
const handleLogoutClick = () => {
setPendingLogoutNavigation(navigation.splashUrl);
userDispatch({ type: "UserClearLocal" });
authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false });
navigator.replace(navigation.splashUrl, { scroll: false });
};
const view = getSidebarViewModel({
const view = getProfileViewModel({
loginStatus: auth.loginStatus,
user,
});
@@ -57,7 +59,7 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
<BackButton
href={navigation.chatUrl}
variant="soft"
analyticsKey="sidebar.back_to_chat"
analyticsKey="profile.back_to_chat"
/>
</div>
@@ -67,12 +69,12 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
name={view.name}
avatarUrl={view.avatarUrl}
isLoading={view.isInitializingUser}
onLoginClick={() => navigator.openAuth(navigation.sidebarUrl)}
onLoginClick={() => navigator.openAuth(navigation.profileUrl)}
/>
</section>
<section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<SidebarWalletCard
<ProfileWalletCard
creditBalance={view.wallet.creditBalance}
dailyFreeChatLimit={view.wallet.dailyFreeChatLimit}
dailyFreeChatRemaining={view.wallet.dailyFreeChatRemaining}
@@ -84,9 +86,9 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
navigator.openSubscription({
type: "vip",
sourceCharacterSlug: navigation.characterSlug,
returnTo: "sidebar",
returnTo: "profile",
analytics: {
entryPoint: "sidebar",
entryPoint: "profile",
triggerReason: "vip_cta",
},
})
@@ -96,10 +98,10 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
navigator.openSubscription({
type: "topup",
sourceCharacterSlug: navigation.characterSlug,
returnTo: "sidebar",
returnTo: "profile",
analytics: {
entryPoint: "sidebar",
triggerReason: "sidebar_recharge",
entryPoint: "profile",
triggerReason: "profile_recharge",
},
})
}
@@ -113,7 +115,7 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
<div className={styles.settingsActions}>
<button
type="button"
data-analytics-key="sidebar.open_feedback"
data-analytics-key="profile.open_feedback"
data-analytics-label="Open feedback"
className={`${styles.logoutCard} ${styles.feedbackCard}`}
onClick={() => navigator.push(navigation.feedbackUrl)}
@@ -155,7 +157,7 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
<button
type="button"
data-analytics-key="sidebar.logout"
data-analytics-key="profile.logout"
data-analytics-label="Log out"
className={styles.logoutCard}
onClick={handleLogoutClick}
@@ -1,17 +1,17 @@
import type { LoginStatus } from "@/data/schemas/auth";
import type { UserView } from "@/stores/user/user-view";
export type SidebarUserState = "guest" | "member" | "vip";
export type ProfileUserState = "guest" | "member" | "vip";
export const SIDEBAR_FALLBACK_USERNAME = "User name";
export const PROFILE_FALLBACK_USERNAME = "User name";
export interface SidebarUserInput {
export interface ProfileUserInput {
currentUser: UserView | null;
avatarUrl: string | null;
creditBalance?: number | null;
}
export interface SidebarWalletView {
export interface ProfileWalletView {
creditBalance: number;
dailyFreeChatLimit: number;
dailyFreeChatRemaining: number;
@@ -19,65 +19,67 @@ export interface SidebarWalletView {
dailyFreePrivateRemaining: number;
}
export interface SidebarViewModel {
state: SidebarUserState;
export interface ProfileViewModel {
state: ProfileUserState;
name: string;
avatarUrl: string | null;
isInitializingUser: boolean;
wallet: SidebarWalletView;
wallet: ProfileWalletView;
canActivateVip: boolean;
canShowSettings: boolean;
}
export function getSidebarViewModel({
export function getProfileViewModel({
loginStatus,
user,
}: {
loginStatus: LoginStatus;
user: SidebarUserInput;
}): SidebarViewModel {
const state = getSidebarUserState(loginStatus, user.currentUser);
user: ProfileUserInput;
}): ProfileViewModel {
const state = getProfileUserState(loginStatus, user.currentUser);
return {
state,
name: user.currentUser?.username ?? SIDEBAR_FALLBACK_USERNAME,
name: user.currentUser?.username ?? PROFILE_FALLBACK_USERNAME,
avatarUrl: user.avatarUrl,
isInitializingUser: state !== "guest" && user.currentUser == null,
wallet: getSidebarWalletView(user),
wallet: getProfileWalletView(user),
canActivateVip: state !== "vip",
canShowSettings: state !== "guest",
};
}
export function getSidebarUserState(
export function getProfileUserState(
loginStatus: LoginStatus,
currentUser: UserView | null,
): SidebarUserState {
if (loginStatus === "notLoggedIn") return "guest";
): ProfileUserState {
if (loginStatus === "notLoggedIn" || loginStatus === "guest") {
return "guest";
}
return currentUser?.isVip === true ? "vip" : "member";
}
export function getSidebarWalletView(
user: SidebarUserInput,
): SidebarWalletView {
export function getProfileWalletView(
user: ProfileUserInput,
): ProfileWalletView {
const currentUser = user.currentUser;
return {
creditBalance: normalizeSidebarCount(user.creditBalance),
dailyFreeChatLimit: normalizeSidebarCount(currentUser?.dailyFreeChatLimit),
dailyFreeChatRemaining: normalizeSidebarCount(
creditBalance: normalizeProfileCount(user.creditBalance),
dailyFreeChatLimit: normalizeProfileCount(currentUser?.dailyFreeChatLimit),
dailyFreeChatRemaining: normalizeProfileCount(
currentUser?.dailyFreeChatRemaining,
),
dailyFreePrivateLimit: normalizeSidebarCount(
dailyFreePrivateLimit: normalizeProfileCount(
currentUser?.dailyFreePrivateLimit,
),
dailyFreePrivateRemaining: normalizeSidebarCount(
dailyFreePrivateRemaining: normalizeProfileCount(
currentUser?.dailyFreePrivateRemaining,
),
};
}
export function normalizeSidebarCount(value: number | null | undefined): number {
export function normalizeProfileCount(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
@@ -4,17 +4,17 @@ import { type Dispatch, useEffect } from "react";
import type { LoginStatus } from "@/data/schemas/auth";
interface SidebarAuthBootstrapState {
interface ProfileAuthBootstrapState {
hasInitialized: boolean;
isLoading: boolean;
loginStatus: LoginStatus;
}
export function useSidebarUserBootstrap({
export function useProfileUserBootstrap({
auth,
userDispatch,
}: {
auth: SidebarAuthBootstrapState;
auth: ProfileAuthBootstrapState;
userDispatch: Dispatch<{ type: "UserFetch" }>;
}) {
useEffect(() => {
@@ -22,7 +22,7 @@ describe("splash Tailwind components", () => {
);
expect(html).toContain("absolute");
expect(html).toContain("bg-sidebar-background");
expect(html).toContain("bg-splash-background");
expect(html).toContain("object-cover");
expect(html).toContain("%2Fimages%2Fcover%2Fmaya.png");
});
@@ -16,7 +16,7 @@ export function SplashBackground({
src: string;
}) {
return (
<div className="absolute inset-0 z-0 overflow-hidden bg-sidebar-background">
<div className="absolute inset-0 z-0 overflow-hidden bg-splash-background">
<Image
src={src}
alt=""
+1 -1
View File
@@ -50,7 +50,7 @@ export function SplashScreen() {
}, []);
return (
<MobileShell background="var(--color-sidebar-background)">
<MobileShell background="var(--color-splash-background)">
<div className={styles.wrapper}>
<SplashBackground src={character.assets.cover} />
{/* 渐变叠加层:accent → transparent (bottom-left → center-right) */}
@@ -19,7 +19,7 @@ describe("SubscriptionPage", () => {
const page = await SubscriptionPage({
searchParams: Promise.resolve({
character: "maya",
returnTo: "sidebar",
returnTo: "profile",
type: "topup",
}),
});
@@ -27,7 +27,7 @@ describe("SubscriptionPage", () => {
expect(renderToStaticMarkup(page)).toContain("Global subscription");
expect(mocks.screenProps).toHaveBeenCalledWith(
expect.objectContaining({
returnTo: "sidebar",
returnTo: "profile",
sourceCharacterSlug: "maya",
subscriptionType: "topup",
}),