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
@@ -16,8 +16,8 @@ describe("global route context", () => {
chatUrl: "/characters/maya/chat",
splashUrl: "/characters/maya/splash",
});
expect(context.sidebarUrl).toBe(
"/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat",
expect(context.profileUrl).toBe(
"/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat",
);
expect(context.feedbackUrl).toBe(
"/feedback?returnTo=%2Fcharacters%2Fmaya%2Fchat",
@@ -52,8 +52,8 @@ describe("global route context", () => {
});
it("sanitizes return targets when building a global page url", () => {
expect(buildGlobalPageUrl(ROUTES.sidebar, "https://example.com")).toBe(
"/sidebar?returnTo=%2Fcharacters%2Felio%2Fchat",
expect(buildGlobalPageUrl(ROUTES.profile, "https://example.com")).toBe(
"/profile?returnTo=%2Fcharacters%2Felio%2Fchat",
);
});
});
@@ -5,11 +5,6 @@ import { LEGACY_GLOBAL_ROUTE_REDIRECTS } from "../legacy-global-route-redirects"
describe("legacy global route redirects", () => {
it("redirects character-scoped global pages without permanent caching", () => {
expect(LEGACY_GLOBAL_ROUTE_REDIRECTS).toEqual([
{
source: "/characters/:characterSlug/sidebar",
destination: "/sidebar?returnTo=/characters/:characterSlug/chat",
permanent: false,
},
{
source: "/characters/:characterSlug/feedback",
destination: "/feedback?returnTo=/characters/:characterSlug/chat",
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
consumePendingLogoutNavigation,
setPendingLogoutNavigation,
} from "../logout-navigation";
describe("logout navigation", () => {
it("consumes the pending destination only once", () => {
setPendingLogoutNavigation("/characters/maya/splash");
expect(consumePendingLogoutNavigation()).toBe(
"/characters/maya/splash",
);
expect(consumePendingLogoutNavigation()).toBeNull();
});
it("keeps only the latest pending destination", () => {
setPendingLogoutNavigation("/characters/elio/splash");
setPendingLogoutNavigation("/characters/nayeli/splash");
expect(consumePendingLogoutNavigation()).toBe(
"/characters/nayeli/splash",
);
});
});
@@ -75,14 +75,22 @@ describe("navigation resolver", () => {
).toBeNull();
});
it("redirects not logged in session routes except chat to splash", () => {
expect(
resolveRouteGuardRedirect({
loginStatus: "notLoggedIn",
pathname: ROUTES.sidebar,
}),
).toBe(ROUTES.splash);
});
it.each(["notLoggedIn", "guest"] as const)(
"redirects %s users from profile to auth with the return context",
(loginStatus) => {
expect(
resolveRouteGuardRedirect({
loginStatus,
pathname: ROUTES.profile,
searchParams: "returnTo=%2Fcharacters%2Fmaya%2Fchat",
}),
).toBe(
ROUTE_BUILDERS.authWithRedirect(
"/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat",
),
);
},
);
it("redirects non-real users on subscription routes to auth", () => {
expect(
+2 -2
View File
@@ -11,7 +11,7 @@ describe("route meta", () => {
expect(getRouteAccess(ROUTES.privateRoom)).toBe("guestEntry");
expect(getRouteAccess(ROUTES.tip)).toBe("public");
expect(getRouteAccess(ROUTES.externalEntry)).toBe("public");
expect(getRouteAccess(ROUTES.sidebar)).toBe("session");
expect(getRouteAccess(ROUTES.profile)).toBe("realUser");
expect(getRouteAccess(ROUTES.feedback)).toBe("session");
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
@@ -36,7 +36,7 @@ describe("route meta", () => {
});
it("does not classify removed character-scoped global pages", () => {
expect(getRouteAccess("/characters/maya/sidebar")).toBe("public");
expect(getRouteAccess("/characters/maya/profile")).toBe("public");
expect(getRouteAccess("/characters/maya/feedback")).toBe("public");
expect(getRouteAccess("/characters/maya/coins-rules")).toBe("public");
});
+7
View File
@@ -9,6 +9,7 @@ import {
useAuthSelector,
} from "@/stores/auth/auth-context";
import { consumePendingLogoutNavigation } from "./logout-navigation";
import { resolveRouteGuardRedirect } from "./navigation-resolver";
export function AppNavigationGuard() {
@@ -26,6 +27,12 @@ export function AppNavigationGuard() {
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
const logoutRedirect = consumePendingLogoutNavigation();
if (logoutRedirect) {
router.replace(logoutRedirect);
return;
}
const redirectTo = resolveRouteGuardRedirect({
loginStatus: authState.loginStatus,
pathname,
+3 -3
View File
@@ -9,7 +9,7 @@ import { getCharacterRoutes, ROUTES } from "./routes";
export const GLOBAL_RETURN_TO_PARAM = "returnTo";
export type GlobalPageRoute =
| typeof ROUTES.sidebar
| typeof ROUTES.profile
| typeof ROUTES.feedback
| typeof ROUTES.coinsRules;
@@ -17,7 +17,7 @@ export interface GlobalRouteContext {
readonly characterSlug: CharacterProfile["slug"];
readonly chatUrl: string;
readonly splashUrl: string;
readonly sidebarUrl: string;
readonly profileUrl: string;
readonly feedbackUrl: string;
readonly coinsRulesUrl: string;
}
@@ -39,7 +39,7 @@ export function resolveGlobalRouteContext(
characterSlug: character.slug,
chatUrl,
splashUrl: characterRoutes.splash,
sidebarUrl: buildGlobalPageUrl(ROUTES.sidebar, chatUrl),
profileUrl: buildGlobalPageUrl(ROUTES.profile, chatUrl),
feedbackUrl: buildGlobalPageUrl(ROUTES.feedback, chatUrl),
coinsRulesUrl: buildGlobalPageUrl(ROUTES.coinsRules, chatUrl),
});
@@ -6,11 +6,6 @@ export interface LegacyGlobalRouteRedirect {
export const LEGACY_GLOBAL_ROUTE_REDIRECTS: readonly LegacyGlobalRouteRedirect[] =
Object.freeze([
{
source: "/characters/:characterSlug/sidebar",
destination: "/sidebar?returnTo=/characters/:characterSlug/chat",
permanent: false,
},
{
source: "/characters/:characterSlug/feedback",
destination: "/feedback?returnTo=/characters/:characterSlug/chat",
+11
View File
@@ -0,0 +1,11 @@
let pendingLogoutUrl: string | null = null;
export function setPendingLogoutNavigation(url: string): void {
pendingLogoutUrl = url;
}
export function consumePendingLogoutNavigation(): string | null {
const url = pendingLogoutUrl;
pendingLogoutUrl = null;
return url;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export type AppSubscriptionType = "vip" | "topup";
export type AppSubscriptionReturnTo =
| "chat"
| "private-room"
| "sidebar"
| "profile"
| null;
export interface OpenSubscriptionInput {
+1 -1
View File
@@ -15,7 +15,7 @@ const GLOBAL_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.privateRoom]: "guestEntry",
[ROUTES.tip]: "public",
[ROUTES.externalEntry]: "public",
[ROUTES.sidebar]: "session",
[ROUTES.profile]: "realUser",
[ROUTES.feedback]: "session",
[ROUTES.subscription]: "realUser",
[ROUTES.coinsRules]: "public",
+2 -2
View File
@@ -25,7 +25,7 @@ export const ROUTES = {
tip: "/tip",
externalEntry: "/external-entry",
auth: "/auth",
sidebar: "/sidebar",
profile: "/profile",
feedback: "/feedback",
subscription: "/subscription",
coinsRules: "/coins-rules",
@@ -114,7 +114,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
ROUTES.tip,
ROUTES.externalEntry,
ROUTES.auth,
ROUTES.sidebar,
ROUTES.profile,
ROUTES.feedback,
ROUTES.coinsRules,
] as const;