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
@@ -0,0 +1,122 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProfileScreen } from "../profile-screen";
const mocks = vi.hoisted(() => ({
openAuth: vi.fn(),
openSubscription: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
authDispatch: vi.fn(),
userDispatch: vi.fn(),
}));
vi.mock("@/router/use-global-app-navigator", () => ({
useGlobalAppNavigator: () => ({
openAuth: mocks.openAuth,
openSubscription: mocks.openSubscription,
push: mocks.push,
replace: mocks.replace,
}),
}));
vi.mock("@/stores/auth/auth-context", () => ({
useAuthDispatch: () => mocks.authDispatch,
useAuthState: () => ({
hasInitialized: true,
isLoading: false,
loginStatus: "email",
}),
}));
vi.mock("@/stores/user/user-context", () => ({
useUserDispatch: () => mocks.userDispatch,
useUserState: () => ({
avatarUrl: null,
creditBalance: 120,
currentUser: {
id: "user-1",
username: "Chase",
countryCode: "US",
creditBalance: 120,
dailyFreeChatLimit: 30,
dailyFreeChatRemaining: 20,
dailyFreePrivateLimit: 2,
dailyFreePrivateRemaining: 1,
isVip: false,
},
}),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
vi.mock("../use-pwa-install-entry", () => ({
usePwaInstallEntry: () => ({
canInstall: false,
installApp: vi.fn(),
}),
}));
vi.mock("../use-profile-user-bootstrap", () => ({
useProfileUserBootstrap: vi.fn(),
}));
describe("ProfileScreen", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
Object.values(mocks).forEach((mock) => mock.mockReset());
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders without CharacterProvider and preserves the source character", () => {
act(() => {
root.render(<ProfileScreen returnTo="/characters/maya/chat" />);
});
expect(
container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="profile.back_to_chat"]',
)?.getAttribute("href"),
).toBe("/characters/maya/chat");
clickButton("Coin Usage Rules");
expect(mocks.push).toHaveBeenCalledWith(
"/coins-rules?returnTo=%2Fcharacters%2Fmaya%2Fchat",
);
clickButton("Feedback");
expect(mocks.push).toHaveBeenCalledWith(
"/feedback?returnTo=%2Fcharacters%2Fmaya%2Fchat",
);
clickButton("Top Up");
expect(mocks.openSubscription).toHaveBeenCalledWith(
expect.objectContaining({
type: "topup",
sourceCharacterSlug: "maya",
returnTo: "profile",
}),
);
});
function clickButton(label: string): void {
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes(label),
);
if (!button) throw new Error(`Missing ${label} button`);
act(() => button.click());
}
});
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import type { UserView } from "@/stores/user/user-view";
import {
getProfileViewModel,
getProfileWalletView,
normalizeProfileCount,
} from "../profile-view-model";
const baseUser: UserView = {
id: "user-1",
username: "Chase",
countryCode: "US",
creditBalance: 120,
dailyFreeChatLimit: 30,
dailyFreeChatRemaining: 24,
dailyFreePrivateLimit: 4,
dailyFreePrivateRemaining: 2,
isVip: false,
};
describe("getProfileViewModel", () => {
it("derives guest state for logged-out users", () => {
const view = getProfileViewModel({
loginStatus: "notLoggedIn",
user: {
currentUser: null,
avatarUrl: null,
creditBalance: 0,
},
});
expect(view.state).toBe("guest");
expect(view.canActivateVip).toBe(true);
expect(view.canShowSettings).toBe(false);
});
it("derives member state and shows VIP activation for non-VIP users", () => {
const view = getProfileViewModel({
loginStatus: "email",
user: {
currentUser: baseUser,
avatarUrl: "https://example.com/avatar.png",
creditBalance: 120,
},
});
expect(view.state).toBe("member");
expect(view.name).toBe("Chase");
expect(view.avatarUrl).toBe("https://example.com/avatar.png");
expect(view.canActivateVip).toBe(true);
expect(view.canShowSettings).toBe(true);
});
it("derives VIP state and hides VIP activation for VIP users", () => {
const view = getProfileViewModel({
loginStatus: "email",
user: {
currentUser: { ...baseUser, isVip: true },
avatarUrl: null,
creditBalance: 120,
},
});
expect(view.state).toBe("vip");
expect(view.canActivateVip).toBe(false);
});
it("keeps Guest users in the signed-out profile state", () => {
const view = getProfileViewModel({
loginStatus: "guest",
user: {
currentUser: null,
avatarUrl: null,
creditBalance: null,
},
});
expect(view.state).toBe("guest");
expect(view.name).toBe("User name");
expect(view.isInitializingUser).toBe(false);
});
});
describe("getProfileWalletView", () => {
it("normalizes missing, negative, and fractional wallet values", () => {
const view = getProfileWalletView({
currentUser: {
...baseUser,
dailyFreeChatLimit: -1,
dailyFreeChatRemaining: 12.9,
dailyFreePrivateLimit: Number.NaN,
dailyFreePrivateRemaining: 3.7,
},
avatarUrl: null,
creditBalance: -42.8,
});
expect(view).toEqual({
creditBalance: 0,
dailyFreeChatLimit: 0,
dailyFreeChatRemaining: 12,
dailyFreePrivateLimit: 0,
dailyFreePrivateRemaining: 3,
});
});
it("normalizes undefined and null counts to zero", () => {
expect(normalizeProfileCount(undefined)).toBe(0);
expect(normalizeProfileCount(null)).toBe(0);
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { shouldKeepPwaInstallEntryVisible } from "../use-pwa-install-entry";
describe("shouldKeepPwaInstallEntryVisible", () => {
it("hides the install entry after accepted installs", () => {
expect(
shouldKeepPwaInstallEntryVisible({
result: "accepted",
isInstalled: true,
canShowInstallEntry: true,
}),
).toBe(false);
});
it("keeps the install entry after dismissed prompts when still available", () => {
expect(
shouldKeepPwaInstallEntryVisible({
result: "dismissed",
isInstalled: false,
canShowInstallEntry: true,
}),
).toBe(true);
});
it("keeps the install entry after unavailable prompts when still available", () => {
expect(
shouldKeepPwaInstallEntryVisible({
result: "unavailable",
isInstalled: false,
canShowInstallEntry: true,
}),
).toBe(true);
});
it("hides the install entry when the platform can no longer show it", () => {
expect(
shouldKeepPwaInstallEntryVisible({
result: "dismissed",
isInstalled: false,
canShowInstallEntry: false,
}),
).toBe(false);
});
});