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:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export type AppSubscriptionType = "vip" | "topup";
|
||||
export type AppSubscriptionReturnTo =
|
||||
| "chat"
|
||||
| "private-room"
|
||||
| "sidebar"
|
||||
| "profile"
|
||||
| null;
|
||||
|
||||
export interface OpenSubscriptionInput {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user