feat(characters): use local character catalog
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getRouteAccess, isInternalRoute } from "../route-meta";
|
||||
import { ALL_STATIC_ROUTES, ROUTES } from "../routes";
|
||||
import { ALL_STATIC_ROUTES, getCharacterRoutes, ROUTES } from "../routes";
|
||||
|
||||
describe("route meta", () => {
|
||||
it("classifies static routes by access level", () => {
|
||||
@@ -21,6 +21,17 @@ describe("route meta", () => {
|
||||
expect(getRouteAccess("/chat/image/msg_1")).toBe("public");
|
||||
});
|
||||
|
||||
it("classifies character-scoped routes by their page", () => {
|
||||
const routes = getCharacterRoutes("maya");
|
||||
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("includes private room in static routes", () => {
|
||||
expect(ALL_STATIC_ROUTES).toContain(ROUTES.privateRoom);
|
||||
});
|
||||
|
||||
@@ -21,8 +21,22 @@ const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
|
||||
[ROUTES.coinsRules]: "public",
|
||||
};
|
||||
|
||||
const CHARACTER_ROUTE_ACCESS: Readonly<Record<string, RouteAccess>> = {
|
||||
splash: "authOnly",
|
||||
chat: "guestEntry",
|
||||
"private-room": "guestEntry",
|
||||
tip: "public",
|
||||
sidebar: "session",
|
||||
feedback: "session",
|
||||
"coins-rules": "public",
|
||||
};
|
||||
|
||||
export function getRouteAccess(pathname: string): RouteAccess {
|
||||
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
|
||||
const staticAccess = STATIC_ROUTE_ACCESS[pathname];
|
||||
if (staticAccess) return staticAccess;
|
||||
|
||||
const match = pathname.match(/^\/characters\/[^/]+\/([^/]+)\/?$/);
|
||||
return (match?.[1] && CHARACTER_ROUTE_ACCESS[match[1]]) || "public";
|
||||
}
|
||||
|
||||
export function isInternalRoute(value: string | null | undefined): boolean {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
||||
import type { CharacterProfile } from "@/data/constants/character";
|
||||
import {
|
||||
PAYMENT_ANALYTICS_ENTRY_PARAM,
|
||||
PAYMENT_ANALYTICS_REASON_PARAM,
|
||||
@@ -32,6 +33,52 @@ export const ROUTES = {
|
||||
|
||||
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
||||
|
||||
export interface CharacterRoutes {
|
||||
readonly splash: string;
|
||||
readonly chat: string;
|
||||
readonly privateRoom: string;
|
||||
readonly tip: string;
|
||||
readonly sidebar: string;
|
||||
readonly feedback: string;
|
||||
readonly coinsRules: string;
|
||||
}
|
||||
|
||||
export function getCharacterRoutes(
|
||||
characterSlug: CharacterProfile["slug"],
|
||||
): CharacterRoutes {
|
||||
const root = `/characters/${encodeURIComponent(characterSlug)}`;
|
||||
return {
|
||||
splash: `${root}/splash`,
|
||||
chat: `${root}/chat`,
|
||||
privateRoom: `${root}/private-room`,
|
||||
tip: `${root}/tip`,
|
||||
sidebar: `${root}/sidebar`,
|
||||
feedback: `${root}/feedback`,
|
||||
coinsRules: `${root}/coins-rules`,
|
||||
};
|
||||
}
|
||||
|
||||
export type RouteSearchParams = Record<
|
||||
string,
|
||||
string | readonly string[] | undefined
|
||||
>;
|
||||
|
||||
export function appendRouteSearchParams(
|
||||
pathname: string,
|
||||
searchParams: RouteSearchParams,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
if (typeof value === "string") {
|
||||
params.set(key, value);
|
||||
continue;
|
||||
}
|
||||
value?.forEach((item) => params.append(key, item));
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
/** 动态查询路由构造器 */
|
||||
export const ROUTE_BUILDERS = {
|
||||
subscription: (
|
||||
@@ -39,12 +86,16 @@ export const ROUTE_BUILDERS = {
|
||||
options: {
|
||||
payChannel?: PayChannel;
|
||||
returnTo?: Exclude<AppSubscriptionReturnTo, null>;
|
||||
characterSlug?: 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.analytics) {
|
||||
params.set(
|
||||
PAYMENT_ANALYTICS_ENTRY_PARAM,
|
||||
|
||||
@@ -16,10 +16,13 @@ import {
|
||||
} 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,
|
||||
ROUTES,
|
||||
type Route,
|
||||
} from "./routes";
|
||||
import {
|
||||
@@ -39,8 +42,14 @@ export interface AppNavigator {
|
||||
openAuth: (redirectTo?: string) => void;
|
||||
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
|
||||
openSubscription: (input: OpenSubscriptionInput) => void;
|
||||
exitSubscription: (returnTo: SubscriptionReturnTo) => void;
|
||||
exitSubscriptionAfterSuccess: (returnTo: SubscriptionReturnTo) => void;
|
||||
exitSubscription: (
|
||||
returnTo: SubscriptionReturnTo,
|
||||
characterSlug?: string,
|
||||
) => void;
|
||||
exitSubscriptionAfterSuccess: (
|
||||
returnTo: SubscriptionReturnTo,
|
||||
characterSlug?: string,
|
||||
) => void;
|
||||
startMessageUnlock: (input: StartMessageUnlockInput) => void;
|
||||
openSubscriptionForPendingUnlock: (
|
||||
input: OpenSubscriptionForPendingUnlockInput,
|
||||
@@ -51,6 +60,8 @@ export interface AppNavigator {
|
||||
|
||||
export function useAppNavigator(): AppNavigator {
|
||||
const router = useRouter();
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const loginStatus = useAuthSelector((state) => state.context.loginStatus);
|
||||
const countryCode = useUserSelector(
|
||||
(state) => state.context.currentUser?.countryCode,
|
||||
@@ -69,18 +80,24 @@ export function useAppNavigator(): AppNavigator {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const openAuth = useCallback((redirectTo: string = ROUTES.chat): void => {
|
||||
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
|
||||
}, [router]);
|
||||
const openAuth = useCallback(
|
||||
(redirectTo: string = characterRoutes.chat): void => {
|
||||
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
|
||||
},
|
||||
[characterRoutes.chat, router],
|
||||
);
|
||||
|
||||
const openChat = useCallback((options: { replace?: boolean; scroll?: boolean } = {}): void => {
|
||||
const navOptions = { scroll: options.scroll ?? false };
|
||||
if (options.replace) {
|
||||
router.replace(ROUTES.chat, navOptions);
|
||||
return;
|
||||
}
|
||||
router.push(ROUTES.chat, navOptions);
|
||||
}, [router]);
|
||||
const openChat = useCallback(
|
||||
(options: { replace?: boolean; scroll?: boolean } = {}): void => {
|
||||
const navOptions = { scroll: options.scroll ?? false };
|
||||
if (options.replace) {
|
||||
router.replace(characterRoutes.chat, navOptions);
|
||||
return;
|
||||
}
|
||||
router.push(characterRoutes.chat, navOptions);
|
||||
},
|
||||
[characterRoutes.chat, router],
|
||||
);
|
||||
|
||||
const getDefaultPayChannel = useCallback(() => {
|
||||
return getDefaultPayChannelForCountryCode(countryCode);
|
||||
@@ -97,6 +114,7 @@ export function useAppNavigator(): AppNavigator {
|
||||
const target = ROUTE_BUILDERS.subscription(type, {
|
||||
payChannel,
|
||||
returnTo: returnTo ?? undefined,
|
||||
characterSlug: character.slug,
|
||||
analytics,
|
||||
});
|
||||
|
||||
@@ -113,7 +131,7 @@ export function useAppNavigator(): AppNavigator {
|
||||
}
|
||||
router.push(nextUrl);
|
||||
},
|
||||
[getDefaultPayChannel, loginStatus, router],
|
||||
[character.slug, getDefaultPayChannel, loginStatus, router],
|
||||
);
|
||||
|
||||
const startMessageUnlock = useCallback(
|
||||
@@ -185,17 +203,33 @@ export function useAppNavigator(): AppNavigator {
|
||||
[getDefaultPayChannel, openSubscription],
|
||||
);
|
||||
|
||||
const exitSubscription = useCallback((returnTo: SubscriptionReturnTo): void => {
|
||||
void (async () => {
|
||||
router.replace(await consumeSubscriptionExitUrl(returnTo));
|
||||
})();
|
||||
}, [router]);
|
||||
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): void => {
|
||||
void (async () => {
|
||||
router.replace(await resolveSubscriptionSuccessExitUrl(returnTo));
|
||||
})();
|
||||
}, [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,
|
||||
|
||||
Reference in New Issue
Block a user