;
}) {
- redirect(
- appendRouteSearchParams(
- getCharacterRoutes(DEFAULT_CHARACTER_SLUG).sidebar,
- await searchParams,
- ),
- );
+ const query = await searchParams;
+ return ;
}
diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx
index 73c85914..b8aa4613 100644
--- a/src/app/sidebar/sidebar-screen.tsx
+++ b/src/app/sidebar/sidebar-screen.tsx
@@ -5,8 +5,11 @@ import { signOut } from "next-auth/react";
import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core";
-import { useAppNavigator } from "@/router/use-app-navigator";
-import { useActiveCharacterRoutes } from "@/providers/character-provider";
+import {
+ resolveGlobalRouteContext,
+ type GlobalReturnToValue,
+} from "@/router/global-route-context";
+import { useGlobalAppNavigator } from "@/router/use-global-app-navigator";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
@@ -17,9 +20,13 @@ import { useSidebarUserBootstrap } from "./use-sidebar-user-bootstrap";
import styles from "./components/sidebar-screen.module.css";
-export function SidebarScreen() {
- const navigator = useAppNavigator();
- const characterRoutes = useActiveCharacterRoutes();
+export interface SidebarScreenProps {
+ returnTo?: GlobalReturnToValue;
+}
+
+export function SidebarScreen({ returnTo }: SidebarScreenProps) {
+ const navigator = useGlobalAppNavigator();
+ const navigation = resolveGlobalRouteContext(returnTo);
const user = useUserState();
const userDispatch = useUserDispatch();
const auth = useAuthState();
@@ -32,7 +39,7 @@ export function SidebarScreen() {
userDispatch({ type: "UserClearLocal" });
authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false });
- navigator.replace(characterRoutes.splash, { scroll: false });
+ navigator.replace(navigation.splashUrl, { scroll: false });
};
const view = getSidebarViewModel({
@@ -48,7 +55,7 @@ export function SidebarScreen() {
@@ -60,7 +67,7 @@ export function SidebarScreen() {
name={view.name}
avatarUrl={view.avatarUrl}
isLoading={view.isInitializingUser}
- onLoginClick={() => navigator.openAuth(characterRoutes.sidebar)}
+ onLoginClick={() => navigator.openAuth(navigation.sidebarUrl)}
/>
@@ -76,6 +83,8 @@ export function SidebarScreen() {
? () =>
navigator.openSubscription({
type: "vip",
+ sourceCharacterSlug: navigation.characterSlug,
+ returnTo: "sidebar",
analytics: {
entryPoint: "sidebar",
triggerReason: "vip_cta",
@@ -86,13 +95,15 @@ export function SidebarScreen() {
onTopUp={() =>
navigator.openSubscription({
type: "topup",
+ sourceCharacterSlug: navigation.characterSlug,
+ returnTo: "sidebar",
analytics: {
entryPoint: "sidebar",
triggerReason: "sidebar_recharge",
},
})
}
- onRulesClick={() => navigator.push(characterRoutes.coinsRules)}
+ onRulesClick={() => navigator.push(navigation.coinsRulesUrl)}
/>
@@ -105,7 +116,7 @@ export function SidebarScreen() {
data-analytics-key="sidebar.open_feedback"
data-analytics-label="Open feedback"
className={`${styles.logoutCard} ${styles.feedbackCard}`}
- onClick={() => navigator.push(characterRoutes.feedback)}
+ onClick={() => navigator.push(navigation.feedbackUrl)}
>
({
+ screenProps: vi.fn(),
+}));
+
+vi.mock("../subscription-screen", () => ({
+ SubscriptionScreen: (props: Record) => {
+ mocks.screenProps(props);
+ return Global subscription;
+ },
+}));
+
+import SubscriptionPage from "../page";
+
+describe("SubscriptionPage", () => {
+ it("renders outside CharacterProvider and treats character as return context", async () => {
+ const page = await SubscriptionPage({
+ searchParams: Promise.resolve({
+ character: "maya",
+ returnTo: "sidebar",
+ type: "topup",
+ }),
+ });
+
+ expect(renderToStaticMarkup(page)).toContain("Global subscription");
+ expect(mocks.screenProps).toHaveBeenCalledWith(
+ expect.objectContaining({
+ returnTo: "sidebar",
+ sourceCharacterSlug: "maya",
+ subscriptionType: "topup",
+ }),
+ );
+ });
+
+ it("uses Elio for missing source navigation context", async () => {
+ const page = await SubscriptionPage({
+ searchParams: Promise.resolve({}),
+ });
+
+ renderToStaticMarkup(page);
+ expect(mocks.screenProps).toHaveBeenLastCalledWith(
+ expect.objectContaining({ sourceCharacterSlug: "elio" }),
+ );
+ });
+});
diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx
index 8609710d..12c595b6 100644
--- a/src/app/subscription/components/subscription-checkout-button.tsx
+++ b/src/app/subscription/components/subscription-checkout-button.tsx
@@ -23,14 +23,14 @@ export interface SubscriptionCheckoutButtonProps {
disabled?: boolean;
subscriptionType: "vip" | "topup";
returnTo?: SubscriptionReturnTo;
- characterSlug?: string;
+ sourceCharacterSlug?: string;
}
export function SubscriptionCheckoutButton({
disabled = false,
subscriptionType,
returnTo = null,
- characterSlug = DEFAULT_CHARACTER_SLUG,
+ sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
}: SubscriptionCheckoutButtonProps) {
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
@@ -40,7 +40,7 @@ export function SubscriptionCheckoutButton({
payment,
paymentDispatch,
returnTo: returnTo ?? undefined,
- characterSlug,
+ characterSlug: sourceCharacterSlug,
subscriptionType,
});
diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx
index 8ea92ee5..1bb53256 100644
--- a/src/app/subscription/page.tsx
+++ b/src/app/subscription/page.tsx
@@ -29,7 +29,7 @@ export default async function SubscriptionPage({
const subscriptionType = toSubscriptionType(
getFirstPaymentSearchParam(query.type),
);
- const characterSlug =
+ const sourceCharacterSlug =
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
DEFAULT_CHARACTER_SLUG;
const analyticsContext = parsePaymentAnalyticsContext({
@@ -49,7 +49,7 @@ export default async function SubscriptionPage({
returnTo={parseSubscriptionReturnTo(query.returnTo)}
initialPayChannel={paymentReturn.initialPayChannel}
analyticsContext={analyticsContext}
- characterSlug={characterSlug}
+ sourceCharacterSlug={sourceCharacterSlug}
/>
);
}
diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx
index 8b424f39..d60c7cd5 100644
--- a/src/app/subscription/subscription-screen.tsx
+++ b/src/app/subscription/subscription-screen.tsx
@@ -43,7 +43,7 @@ export interface SubscriptionScreenProps {
returnTo?: SubscriptionReturnTo;
initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext;
- characterSlug?: string;
+ sourceCharacterSlug?: string;
}
export function SubscriptionScreen({
@@ -52,7 +52,7 @@ export function SubscriptionScreen({
returnTo = null,
initialPayChannel = null,
analyticsContext: providedAnalyticsContext,
- characterSlug = DEFAULT_CHARACTER_SLUG,
+ sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
}: SubscriptionScreenProps) {
const userState = useUserState();
const countryCode = userState.currentUser?.countryCode;
@@ -70,7 +70,7 @@ export function SubscriptionScreen({
subscriptionType,
shouldResumePendingOrder,
returnTo,
- characterSlug,
+ sourceCharacterSlug,
initialPayChannel: paymentMethodConfig.initialPayChannel,
});
const canSubscribeVip = subscriptionType === "vip";
@@ -242,7 +242,7 @@ export function SubscriptionScreen({
disabled={!canActivate}
subscriptionType={subscriptionType}
returnTo={returnTo}
- characterSlug={characterSlug}
+ sourceCharacterSlug={sourceCharacterSlug}
/>
diff --git a/src/app/subscription/use-subscription-payment-flow.ts b/src/app/subscription/use-subscription-payment-flow.ts
index eb54977e..859cb75b 100644
--- a/src/app/subscription/use-subscription-payment-flow.ts
+++ b/src/app/subscription/use-subscription-payment-flow.ts
@@ -19,7 +19,7 @@ export interface UseSubscriptionPaymentFlowInput {
shouldResumePendingOrder: boolean;
returnTo: SubscriptionReturnTo;
initialPayChannel: PayChannel;
- characterSlug?: string;
+ sourceCharacterSlug?: string;
}
export function useSubscriptionPaymentFlow({
@@ -27,7 +27,7 @@ export function useSubscriptionPaymentFlow({
shouldResumePendingOrder,
returnTo,
initialPayChannel,
- characterSlug = DEFAULT_CHARACTER_SLUG,
+ sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
}: UseSubscriptionPaymentFlowInput) {
const router = useRouter();
const { payment, paymentDispatch } = usePaymentRouteFlow({
@@ -50,7 +50,7 @@ export function useSubscriptionPaymentFlow({
const handleBackClick = () => {
void (async () => {
router.replace(
- await consumeSubscriptionExitUrl(returnTo, characterSlug),
+ await consumeSubscriptionExitUrl(returnTo, sourceCharacterSlug),
);
})();
};
@@ -60,7 +60,10 @@ export function useSubscriptionPaymentFlow({
paymentDispatch({ type: "PaymentReset" });
void (async () => {
router.replace(
- await resolveSubscriptionSuccessExitUrl(returnTo, characterSlug),
+ await resolveSubscriptionSuccessExitUrl(
+ returnTo,
+ sourceCharacterSlug,
+ ),
);
})();
};
diff --git a/src/data/storage/payment/pending_payment_order_storage.ts b/src/data/storage/payment/pending_payment_order_storage.ts
index 983fd778..3172bd69 100644
--- a/src/data/storage/payment/pending_payment_order_storage.ts
+++ b/src/data/storage/payment/pending_payment_order_storage.ts
@@ -12,7 +12,7 @@ const PendingPaymentOrderSchema = z.object({
payChannel: z.literal("ezpay"),
subscriptionType: z.enum(["vip", "topup", "tip"]),
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
- returnTo: z.enum(["chat", "private-room"]).optional(),
+ returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
createdAt: z.number(),
});
diff --git a/src/lib/navigation/__tests__/subscription_exit.test.ts b/src/lib/navigation/__tests__/subscription_exit.test.ts
index 6da32fa5..5bc2edc5 100644
--- a/src/lib/navigation/__tests__/subscription_exit.test.ts
+++ b/src/lib/navigation/__tests__/subscription_exit.test.ts
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { getCharacterRoutes } from "@/router/routes";
+import { buildGlobalPageUrl } from "@/router/global-route-context";
+import { getCharacterRoutes, ROUTES } from "@/router/routes";
import { consumePendingChatImageReturn } from "../chat_image_return_session";
import {
@@ -63,14 +64,31 @@ describe("subscription exit helpers", () => {
consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue(null);
- expect(getSubscriptionFallbackExitUrl(null)).toBe(
- getCharacterRoutes("elio").sidebar,
- );
+ expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
await expect(consumeSubscriptionExitUrl(null)).resolves.toBe(
- getCharacterRoutes("elio").sidebar,
+ ROUTES.sidebar,
);
});
+ it("returns to global sidebar with the source character", async () => {
+ const expectedUrl = buildGlobalPageUrl(
+ ROUTES.sidebar,
+ getCharacterRoutes("maya").chat,
+ );
+
+ expect(getSubscriptionFallbackExitUrl("sidebar", "maya")).toBe(
+ expectedUrl,
+ );
+ await expect(
+ consumeSubscriptionExitUrl("sidebar", "maya"),
+ ).resolves.toBe(expectedUrl);
+ await expect(
+ resolveSubscriptionSuccessExitUrl("sidebar", "maya"),
+ ).resolves.toBe(expectedUrl);
+ expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled();
+ expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
+ });
+
it("returns directly to private room without consuming chat state", async () => {
consumePendingChatImageReturnMock.mockResolvedValue({
reason: "image_paywall",
diff --git a/src/lib/navigation/subscription_exit.ts b/src/lib/navigation/subscription_exit.ts
index c2dd18d9..c2cb5dd1 100644
--- a/src/lib/navigation/subscription_exit.ts
+++ b/src/lib/navigation/subscription_exit.ts
@@ -5,7 +5,8 @@ import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
-import { getCharacterRoutes } from "@/router/routes";
+import { buildGlobalPageUrl } from "@/router/global-route-context";
+import { getCharacterRoutes, ROUTES } from "@/router/routes";
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
import { consumePendingChatImageReturn } from "./chat_image_return_session";
@@ -47,7 +48,10 @@ export function getSubscriptionFallbackExitUrl(
const routes = getCharacterRoutes(characterSlug);
if (returnTo === "chat") return routes.chat;
if (returnTo === "private-room") return routes.privateRoom;
- return routes.sidebar;
+ if (returnTo === "sidebar") {
+ return buildGlobalPageUrl(ROUTES.sidebar, routes.chat);
+ }
+ return ROUTES.sidebar;
}
export async function consumeSubscriptionExitUrl(
@@ -57,6 +61,9 @@ export async function consumeSubscriptionExitUrl(
if (returnTo === "private-room") {
return getCharacterRoutes(characterSlug).privateRoom;
}
+ if (returnTo === "sidebar") {
+ return getSubscriptionFallbackExitUrl(returnTo, characterSlug);
+ }
return (
(await consumeSubscriptionExplicitExitUrl(characterSlug)) ??
@@ -71,6 +78,9 @@ export async function resolveSubscriptionSuccessExitUrl(
if (returnTo === "private-room") {
return getCharacterRoutes(characterSlug).privateRoom;
}
+ if (returnTo === "sidebar") {
+ return getSubscriptionFallbackExitUrl(returnTo, characterSlug);
+ }
const pendingExitUrl = await peekSubscriptionExplicitExitUrl(characterSlug);
if (pendingExitUrl) return pendingExitUrl;
diff --git a/src/lib/payment/__tests__/payment_search_params.test.ts b/src/lib/payment/__tests__/payment_search_params.test.ts
index 313aa332..780f8468 100644
--- a/src/lib/payment/__tests__/payment_search_params.test.ts
+++ b/src/lib/payment/__tests__/payment_search_params.test.ts
@@ -40,6 +40,7 @@ describe("payment search params", () => {
it("accepts only supported subscription return targets", () => {
expect(parseSubscriptionReturnTo("chat")).toBe("chat");
expect(parseSubscriptionReturnTo("private-room")).toBe("private-room");
+ expect(parseSubscriptionReturnTo("sidebar")).toBe("sidebar");
expect(parseSubscriptionReturnTo(["private-room", "chat"])).toBe(
"private-room",
);
diff --git a/src/lib/payment/__tests__/pending_payment_order.test.ts b/src/lib/payment/__tests__/pending_payment_order.test.ts
index e829c207..55dd9b5e 100644
--- a/src/lib/payment/__tests__/pending_payment_order.test.ts
+++ b/src/lib/payment/__tests__/pending_payment_order.test.ts
@@ -54,6 +54,40 @@ describe("pending payment order helpers", () => {
await clearPendingPaymentOrder();
});
+ it("stores and rebuilds global sidebar returns", async () => {
+ await clearPendingPaymentOrder();
+
+ const saveResult = await savePendingEzpayOrder({
+ orderId: "order-sidebar",
+ returnTo: "sidebar",
+ subscriptionType: "topup",
+ characterSlug: "maya",
+ createdAt: 1,
+ });
+ const storedResult = await getPendingPaymentOrder();
+
+ expect(saveResult.success).toBe(true);
+ expect(storedResult).toMatchObject({
+ success: true,
+ data: {
+ orderId: "order-sidebar",
+ returnTo: "sidebar",
+ characterSlug: "maya",
+ },
+ });
+ expect(
+ buildPendingPaymentSubscriptionUrl({
+ payChannel: "ezpay",
+ returnTo: "sidebar",
+ subscriptionType: "topup",
+ characterSlug: "maya",
+ }),
+ ).toBe(
+ "/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=sidebar&character=maya",
+ );
+ await clearPendingPaymentOrder();
+ });
+
it("routes tip payments back to the tip page", () => {
expect(
buildPendingPaymentSubscriptionUrl({
diff --git a/src/lib/payment/payment_search_params.ts b/src/lib/payment/payment_search_params.ts
index f6c58fc9..3508eba7 100644
--- a/src/lib/payment/payment_search_params.ts
+++ b/src/lib/payment/payment_search_params.ts
@@ -26,7 +26,13 @@ export function parseSubscriptionReturnTo(
value: PaymentSearchParamValue,
): AppSubscriptionReturnTo {
const returnTo = getFirstPaymentSearchParam(value);
- if (returnTo === "chat" || returnTo === "private-room") return returnTo;
+ if (
+ returnTo === "chat" ||
+ returnTo === "private-room" ||
+ returnTo === "sidebar"
+ ) {
+ return returnTo;
+ }
return null;
}
diff --git a/src/router/__tests__/global-route-context.test.ts b/src/router/__tests__/global-route-context.test.ts
new file mode 100644
index 00000000..e9ee4b15
--- /dev/null
+++ b/src/router/__tests__/global-route-context.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildGlobalPageUrl,
+ resolveGlobalRouteContext,
+ resolveGlobalRouteContextValue,
+} from "../global-route-context";
+import { ROUTES } from "../routes";
+
+describe("global route context", () => {
+ it("preserves a known character chat as the return target", () => {
+ const context = resolveGlobalRouteContext("/characters/maya/chat");
+
+ expect(context).toMatchObject({
+ characterSlug: "maya",
+ chatUrl: "/characters/maya/chat",
+ splashUrl: "/characters/maya/splash",
+ });
+ expect(context.sidebarUrl).toBe(
+ "/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat",
+ );
+ expect(context.feedbackUrl).toBe(
+ "/feedback?returnTo=%2Fcharacters%2Fmaya%2Fchat",
+ );
+ expect(context.coinsRulesUrl).toBe(
+ "/coins-rules?returnTo=%2Fcharacters%2Fmaya%2Fchat",
+ );
+ });
+
+ it("uses only the first repeated return target", () => {
+ expect(
+ resolveGlobalRouteContextValue([
+ "/characters/nayeli/chat",
+ "/characters/maya/chat",
+ ]),
+ ).toBe("/characters/nayeli/chat");
+ });
+
+ it.each([
+ undefined,
+ null,
+ "",
+ "https://example.com/characters/maya/chat",
+ "//example.com/characters/maya/chat",
+ "/characters/unknown/chat",
+ "/characters/maya/private-room",
+ "/characters/maya/chat?image=1",
+ ])("falls back to Elio for an invalid return target", (value) => {
+ expect(resolveGlobalRouteContextValue(value)).toBe(
+ "/characters/elio/chat",
+ );
+ });
+
+ it("sanitizes return targets when building a global page url", () => {
+ expect(buildGlobalPageUrl(ROUTES.sidebar, "https://example.com")).toBe(
+ "/sidebar?returnTo=%2Fcharacters%2Felio%2Fchat",
+ );
+ });
+});
diff --git a/src/router/__tests__/legacy-global-route-redirects.test.ts b/src/router/__tests__/legacy-global-route-redirects.test.ts
new file mode 100644
index 00000000..ff3308d6
--- /dev/null
+++ b/src/router/__tests__/legacy-global-route-redirects.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+
+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",
+ permanent: false,
+ },
+ {
+ source: "/characters/:characterSlug/coins-rules",
+ destination:
+ "/coins-rules?returnTo=/characters/:characterSlug/chat",
+ permanent: false,
+ },
+ ]);
+ });
+});
diff --git a/src/router/__tests__/navigation-resolver.test.ts b/src/router/__tests__/navigation-resolver.test.ts
index e111f28a..74eb8974 100644
--- a/src/router/__tests__/navigation-resolver.test.ts
+++ b/src/router/__tests__/navigation-resolver.test.ts
@@ -59,9 +59,10 @@ describe("navigation resolver", () => {
ROUTE_BUILDERS.subscription("topup", {
payChannel: "stripe",
returnTo: "private-room",
+ sourceCharacterSlug: "maya",
}),
).toBe(
- "/subscription?type=topup&payChannel=stripe&returnTo=private-room",
+ "/subscription?type=topup&payChannel=stripe&returnTo=private-room&character=maya",
);
});
diff --git a/src/router/__tests__/route-meta.test.ts b/src/router/__tests__/route-meta.test.ts
index 3007eae4..cd4a3744 100644
--- a/src/router/__tests__/route-meta.test.ts
+++ b/src/router/__tests__/route-meta.test.ts
@@ -4,7 +4,7 @@ import { getRouteAccess, isInternalRoute } from "../route-meta";
import { ALL_STATIC_ROUTES, getCharacterRoutes, ROUTES } from "../routes";
describe("route meta", () => {
- it("classifies static routes by access level", () => {
+ it("classifies global routes by access level", () => {
expect(getRouteAccess(ROUTES.splash)).toBe("authOnly");
expect(getRouteAccess(ROUTES.auth)).toBe("authOnly");
expect(getRouteAccess(ROUTES.chat)).toBe("guestEntry");
@@ -23,13 +23,22 @@ describe("route meta", () => {
it("classifies character-scoped routes by their page", () => {
const routes = getCharacterRoutes("maya");
+ expect(routes).toEqual({
+ splash: "/characters/maya/splash",
+ chat: "/characters/maya/chat",
+ privateRoom: "/characters/maya/private-room",
+ tip: "/characters/maya/tip",
+ });
expect(getRouteAccess(routes.splash)).toBe("authOnly");
expect(getRouteAccess(routes.chat)).toBe("guestEntry");
expect(getRouteAccess(routes.privateRoom)).toBe("guestEntry");
expect(getRouteAccess(routes.tip)).toBe("public");
- expect(getRouteAccess(routes.sidebar)).toBe("session");
- expect(getRouteAccess(routes.feedback)).toBe("session");
- expect(getRouteAccess(routes.coinsRules)).toBe("public");
+ });
+
+ it("does not classify removed character-scoped global pages", () => {
+ expect(getRouteAccess("/characters/maya/sidebar")).toBe("public");
+ expect(getRouteAccess("/characters/maya/feedback")).toBe("public");
+ expect(getRouteAccess("/characters/maya/coins-rules")).toBe("public");
});
it("includes private room in static routes", () => {
diff --git a/src/router/global-route-context.ts b/src/router/global-route-context.ts
new file mode 100644
index 00000000..2a11435f
--- /dev/null
+++ b/src/router/global-route-context.ts
@@ -0,0 +1,76 @@
+import {
+ DEFAULT_CHARACTER,
+ getCharacterBySlug,
+ type CharacterProfile,
+} from "@/data/constants/character";
+
+import { getCharacterRoutes, ROUTES } from "./routes";
+
+export const GLOBAL_RETURN_TO_PARAM = "returnTo";
+
+export type GlobalPageRoute =
+ | typeof ROUTES.sidebar
+ | typeof ROUTES.feedback
+ | typeof ROUTES.coinsRules;
+
+export interface GlobalRouteContext {
+ readonly characterSlug: CharacterProfile["slug"];
+ readonly chatUrl: string;
+ readonly splashUrl: string;
+ readonly sidebarUrl: string;
+ readonly feedbackUrl: string;
+ readonly coinsRulesUrl: string;
+}
+
+export type GlobalReturnToValue =
+ | string
+ | readonly string[]
+ | null
+ | undefined;
+
+export function resolveGlobalRouteContext(
+ value?: GlobalReturnToValue,
+): GlobalRouteContext {
+ const character = resolveReturnCharacter(getFirstValue(value));
+ const characterRoutes = getCharacterRoutes(character.slug);
+ const chatUrl = characterRoutes.chat;
+
+ return Object.freeze({
+ characterSlug: character.slug,
+ chatUrl,
+ splashUrl: characterRoutes.splash,
+ sidebarUrl: buildGlobalPageUrl(ROUTES.sidebar, chatUrl),
+ feedbackUrl: buildGlobalPageUrl(ROUTES.feedback, chatUrl),
+ coinsRulesUrl: buildGlobalPageUrl(ROUTES.coinsRules, chatUrl),
+ });
+}
+
+export function buildGlobalPageUrl(
+ route: GlobalPageRoute,
+ returnTo: string,
+): string {
+ const safeReturnTo = resolveGlobalRouteContextValue(returnTo);
+ const params = new URLSearchParams({
+ [GLOBAL_RETURN_TO_PARAM]: safeReturnTo,
+ });
+ return `${route}?${params.toString()}`;
+}
+
+export function resolveGlobalRouteContextValue(
+ value?: GlobalReturnToValue,
+): string {
+ const character = resolveReturnCharacter(getFirstValue(value));
+ return getCharacterRoutes(character.slug).chat;
+}
+
+function resolveReturnCharacter(value: string | null): CharacterProfile {
+ const match = value?.match(/^\/characters\/([^/?#]+)\/chat$/);
+ if (!match?.[1]) return DEFAULT_CHARACTER;
+ return getCharacterBySlug(match[1]) ?? DEFAULT_CHARACTER;
+}
+
+function getFirstValue(value: GlobalReturnToValue): string | null {
+ if (typeof value === "string") return value;
+ if (Array.isArray(value)) return value[0] ?? null;
+ return null;
+}
diff --git a/src/router/legacy-global-route-redirects.ts b/src/router/legacy-global-route-redirects.ts
new file mode 100644
index 00000000..a4f5a0b9
--- /dev/null
+++ b/src/router/legacy-global-route-redirects.ts
@@ -0,0 +1,24 @@
+export interface LegacyGlobalRouteRedirect {
+ readonly source: string;
+ readonly destination: string;
+ readonly permanent: false;
+}
+
+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",
+ permanent: false,
+ },
+ {
+ source: "/characters/:characterSlug/coins-rules",
+ destination: "/coins-rules?returnTo=/characters/:characterSlug/chat",
+ permanent: false,
+ },
+ ]);
diff --git a/src/router/navigation-types.ts b/src/router/navigation-types.ts
index e51e2e1d..8def6006 100644
--- a/src/router/navigation-types.ts
+++ b/src/router/navigation-types.ts
@@ -7,7 +7,11 @@ import type {
} from "@/data/storage/navigation";
export type AppSubscriptionType = "vip" | "topup";
-export type AppSubscriptionReturnTo = "chat" | "private-room" | null;
+export type AppSubscriptionReturnTo =
+ | "chat"
+ | "private-room"
+ | "sidebar"
+ | null;
export interface OpenSubscriptionInput {
type: AppSubscriptionType;
diff --git a/src/router/route-meta.ts b/src/router/route-meta.ts
index dd08561d..59d15df9 100644
--- a/src/router/route-meta.ts
+++ b/src/router/route-meta.ts
@@ -7,7 +7,7 @@ export type RouteAccess =
| "realUser"
| "authOnly";
-const STATIC_ROUTE_ACCESS: Partial> = {
+const GLOBAL_ROUTE_ACCESS: Partial> = {
[ROUTES.root]: "public",
[ROUTES.splash]: "authOnly",
[ROUTES.auth]: "authOnly",
@@ -26,14 +26,11 @@ const CHARACTER_ROUTE_ACCESS: Readonly> = {
chat: "guestEntry",
"private-room": "guestEntry",
tip: "public",
- sidebar: "session",
- feedback: "session",
- "coins-rules": "public",
};
export function getRouteAccess(pathname: string): RouteAccess {
- const staticAccess = STATIC_ROUTE_ACCESS[pathname];
- if (staticAccess) return staticAccess;
+ const globalAccess = GLOBAL_ROUTE_ACCESS[pathname];
+ if (globalAccess) return globalAccess;
const match = pathname.match(/^\/characters\/[^/]+\/([^/]+)\/?$/);
return (match?.[1] && CHARACTER_ROUTE_ACCESS[match[1]]) || "public";
diff --git a/src/router/routes.ts b/src/router/routes.ts
index 65ce27bb..6fff5551 100644
--- a/src/router/routes.ts
+++ b/src/router/routes.ts
@@ -38,9 +38,6 @@ export interface CharacterRoutes {
readonly chat: string;
readonly privateRoom: string;
readonly tip: string;
- readonly sidebar: string;
- readonly feedback: string;
- readonly coinsRules: string;
}
export function getCharacterRoutes(
@@ -52,9 +49,6 @@ export function getCharacterRoutes(
chat: `${root}/chat`,
privateRoom: `${root}/private-room`,
tip: `${root}/tip`,
- sidebar: `${root}/sidebar`,
- feedback: `${root}/feedback`,
- coinsRules: `${root}/coins-rules`,
};
}
@@ -86,15 +80,15 @@ export const ROUTE_BUILDERS = {
options: {
payChannel?: PayChannel;
returnTo?: Exclude;
- characterSlug?: string;
+ sourceCharacterSlug?: string;
analytics?: PaymentAnalyticsContext;
} = {},
): `/subscription?${string}` => {
const params = new URLSearchParams({ type });
if (options.payChannel) params.set("payChannel", options.payChannel);
if (options.returnTo) params.set("returnTo", options.returnTo);
- if (options.characterSlug) {
- params.set("character", options.characterSlug);
+ if (options.sourceCharacterSlug) {
+ params.set("character", options.sourceCharacterSlug);
}
if (options.analytics) {
params.set(
diff --git a/src/router/use-app-navigator.ts b/src/router/use-app-navigator.ts
index a918b1c3..b066f994 100644
--- a/src/router/use-app-navigator.ts
+++ b/src/router/use-app-navigator.ts
@@ -1,39 +1,20 @@
"use client";
import { useCallback, useMemo } from "react";
-import { useRouter } from "next/navigation";
import { NavigationStorage } from "@/data/storage/navigation";
-import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
-import {
- consumeSubscriptionExitUrl,
- resolveSubscriptionSuccessExitUrl,
-} from "@/lib/navigation/subscription_exit";
-import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
-import {
- behaviorAnalytics,
- getDefaultPaymentAnalyticsContext,
-} from "@/lib/analytics";
-import { useAuthSelector } from "@/stores/auth/auth-context";
-import { useUserSelector } from "@/stores/user/user-context";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
-import {
- ROUTE_BUILDERS,
- type Route,
-} from "./routes";
-import {
- isAuthenticatedUser as resolveIsAuthenticatedUser,
- resolveAuthenticatedNavigation,
-} from "./navigation-resolver";
+import { type Route } from "./routes";
import type {
OpenSubscriptionForPendingUnlockInput,
OpenSubscriptionInput,
StartMessageUnlockInput,
} from "./navigation-types";
+import { useGlobalAppNavigator } from "./use-global-app-navigator";
export interface AppNavigator {
push: (href: string, options?: { scroll?: boolean }) => void;
@@ -42,96 +23,55 @@ export interface AppNavigator {
openAuth: (redirectTo?: string) => void;
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
openSubscription: (input: OpenSubscriptionInput) => void;
- exitSubscription: (
- returnTo: SubscriptionReturnTo,
- characterSlug?: string,
- ) => void;
- exitSubscriptionAfterSuccess: (
- returnTo: SubscriptionReturnTo,
- characterSlug?: string,
- ) => void;
startMessageUnlock: (input: StartMessageUnlockInput) => void;
openSubscriptionForPendingUnlock: (
input: OpenSubscriptionForPendingUnlockInput,
) => void;
- getDefaultPayChannel: () => ReturnType;
+ getDefaultPayChannel: ReturnType<
+ typeof useGlobalAppNavigator
+ >["getDefaultPayChannel"];
isAuthenticatedUser: boolean;
}
export function useAppNavigator(): AppNavigator {
- const router = useRouter();
+ const globalNavigator = useGlobalAppNavigator();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
- const loginStatus = useAuthSelector((state) => state.context.loginStatus);
- const countryCode = useUserSelector(
- (state) => state.context.currentUser?.countryCode,
- );
- const isAuthenticatedUser = resolveIsAuthenticatedUser(loginStatus);
-
- const push = useCallback((href: string, options?: { scroll?: boolean }): void => {
- router.push(href, options);
- }, [router]);
-
- const replace = useCallback((href: string, options?: { scroll?: boolean }): void => {
- router.replace(href, options);
- }, [router]);
-
- const back = useCallback((): void => {
- router.back();
- }, [router]);
+ const {
+ back,
+ getDefaultPayChannel,
+ isAuthenticatedUser,
+ push,
+ replace,
+ } = globalNavigator;
const openAuth = useCallback(
(redirectTo: string = characterRoutes.chat): void => {
- router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
+ globalNavigator.openAuth(redirectTo);
},
- [characterRoutes.chat, router],
+ [characterRoutes.chat, globalNavigator],
);
const openChat = useCallback(
(options: { replace?: boolean; scroll?: boolean } = {}): void => {
const navOptions = { scroll: options.scroll ?? false };
if (options.replace) {
- router.replace(characterRoutes.chat, navOptions);
+ replace(characterRoutes.chat, navOptions);
return;
}
- router.push(characterRoutes.chat, navOptions);
+ push(characterRoutes.chat, navOptions);
},
- [characterRoutes.chat, router],
+ [characterRoutes.chat, push, replace],
);
- const getDefaultPayChannel = useCallback(() => {
- return getDefaultPayChannelForCountryCode(countryCode);
- }, [countryCode]);
-
const openSubscription = useCallback(
- ({
- type,
- payChannel = getDefaultPayChannel(),
- returnTo = null,
- replace: shouldReplace = false,
- analytics = getDefaultPaymentAnalyticsContext(type),
- }: OpenSubscriptionInput): void => {
- const target = ROUTE_BUILDERS.subscription(type, {
- payChannel,
- returnTo: returnTo ?? undefined,
- characterSlug: character.slug,
- analytics,
+ (input: OpenSubscriptionInput): void => {
+ globalNavigator.openSubscription({
+ ...input,
+ sourceCharacterSlug: character.slug,
});
-
- behaviorAnalytics.rechargeModalOpen(analytics);
-
- const nextUrl = resolveAuthenticatedNavigation({
- loginStatus,
- targetUrl: target,
- });
-
- if (shouldReplace) {
- router.replace(nextUrl);
- return;
- }
- router.push(nextUrl);
},
- [character.slug, getDefaultPayChannel, loginStatus, router],
+ [character.slug, globalNavigator],
);
const startMessageUnlock = useCallback(
@@ -163,10 +103,10 @@ export function useAppNavigator(): AppNavigator {
returnUrl,
stage,
});
- router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
+ globalNavigator.openAuth(returnUrl);
})();
},
- [character.id, isAuthenticatedUser, router],
+ [character.id, globalNavigator, isAuthenticatedUser],
);
const openSubscriptionForPendingUnlock = useCallback(
@@ -205,34 +145,6 @@ export function useAppNavigator(): AppNavigator {
[character.id, getDefaultPayChannel, openSubscription],
);
- const exitSubscription = useCallback(
- (
- returnTo: SubscriptionReturnTo,
- characterSlug: string = character.slug,
- ): void => {
- void (async () => {
- router.replace(
- await consumeSubscriptionExitUrl(returnTo, characterSlug),
- );
- })();
- },
- [character.slug, router],
- );
-
- const exitSubscriptionAfterSuccess = useCallback(
- (
- returnTo: SubscriptionReturnTo,
- characterSlug: string = character.slug,
- ): void => {
- void (async () => {
- router.replace(
- await resolveSubscriptionSuccessExitUrl(returnTo, characterSlug),
- );
- })();
- },
- [character.slug, router],
- );
-
return useMemo(() => ({
push,
replace,
@@ -240,16 +152,12 @@ export function useAppNavigator(): AppNavigator {
openAuth,
openChat,
openSubscription,
- exitSubscription,
- exitSubscriptionAfterSuccess,
startMessageUnlock,
openSubscriptionForPendingUnlock,
getDefaultPayChannel,
isAuthenticatedUser,
}), [
back,
- exitSubscription,
- exitSubscriptionAfterSuccess,
getDefaultPayChannel,
isAuthenticatedUser,
openAuth,
diff --git a/src/router/use-global-app-navigator.ts b/src/router/use-global-app-navigator.ts
new file mode 100644
index 00000000..c750090a
--- /dev/null
+++ b/src/router/use-global-app-navigator.ts
@@ -0,0 +1,126 @@
+"use client";
+
+import { useCallback, useMemo } from "react";
+import { useRouter } from "next/navigation";
+
+import {
+ behaviorAnalytics,
+ getDefaultPaymentAnalyticsContext,
+} from "@/lib/analytics";
+import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
+import { useAuthSelector } from "@/stores/auth/auth-context";
+import { useUserSelector } from "@/stores/user/user-context";
+
+import {
+ isAuthenticatedUser as resolveIsAuthenticatedUser,
+ resolveAuthenticatedNavigation,
+} from "./navigation-resolver";
+import type { OpenSubscriptionInput } from "./navigation-types";
+import { ROUTE_BUILDERS } from "./routes";
+
+export interface OpenGlobalSubscriptionInput extends OpenSubscriptionInput {
+ sourceCharacterSlug: string;
+}
+
+export interface GlobalAppNavigator {
+ push: (href: string, options?: { scroll?: boolean }) => void;
+ replace: (href: string, options?: { scroll?: boolean }) => void;
+ back: () => void;
+ openAuth: (redirectTo: string) => void;
+ openSubscription: (input: OpenGlobalSubscriptionInput) => void;
+ getDefaultPayChannel: () => ReturnType<
+ typeof getDefaultPayChannelForCountryCode
+ >;
+ isAuthenticatedUser: boolean;
+}
+
+export function useGlobalAppNavigator(): GlobalAppNavigator {
+ const router = useRouter();
+ const loginStatus = useAuthSelector((state) => state.context.loginStatus);
+ const countryCode = useUserSelector(
+ (state) => state.context.currentUser?.countryCode,
+ );
+ const isAuthenticatedUser = resolveIsAuthenticatedUser(loginStatus);
+
+ const push = useCallback(
+ (href: string, options?: { scroll?: boolean }): void => {
+ router.push(href, options);
+ },
+ [router],
+ );
+
+ const replace = useCallback(
+ (href: string, options?: { scroll?: boolean }): void => {
+ router.replace(href, options);
+ },
+ [router],
+ );
+
+ const back = useCallback((): void => {
+ router.back();
+ }, [router]);
+
+ const openAuth = useCallback(
+ (redirectTo: string): void => {
+ router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
+ },
+ [router],
+ );
+
+ const getDefaultPayChannel = useCallback(() => {
+ return getDefaultPayChannelForCountryCode(countryCode);
+ }, [countryCode]);
+
+ const openSubscription = useCallback(
+ ({
+ type,
+ sourceCharacterSlug,
+ payChannel = getDefaultPayChannel(),
+ returnTo = null,
+ replace: shouldReplace = false,
+ analytics = getDefaultPaymentAnalyticsContext(type),
+ }: OpenGlobalSubscriptionInput): void => {
+ const target = ROUTE_BUILDERS.subscription(type, {
+ payChannel,
+ returnTo: returnTo ?? undefined,
+ sourceCharacterSlug,
+ analytics,
+ });
+
+ behaviorAnalytics.rechargeModalOpen(analytics);
+
+ const nextUrl = resolveAuthenticatedNavigation({
+ loginStatus,
+ targetUrl: target,
+ });
+
+ if (shouldReplace) {
+ router.replace(nextUrl);
+ return;
+ }
+ router.push(nextUrl);
+ },
+ [getDefaultPayChannel, loginStatus, router],
+ );
+
+ return useMemo(
+ () => ({
+ push,
+ replace,
+ back,
+ openAuth,
+ openSubscription,
+ getDefaultPayChannel,
+ isAuthenticatedUser,
+ }),
+ [
+ back,
+ getDefaultPayChannel,
+ isAuthenticatedUser,
+ openAuth,
+ openSubscription,
+ push,
+ replace,
+ ],
+ );
+}