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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user