feat(characters): use local character catalog

This commit is contained in:
2026-07-17 16:03:18 +08:00
parent a210a98d98
commit b3ebd5cf3b
96 changed files with 1023 additions and 522 deletions
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LoginStatus } from "@/data/schemas/auth";
import { ROUTES } from "@/router/routes";
import { getCharacterRoutes } from "@/router/routes";
import type { PrivateRoomEvent } from "@/stores/private-room";
import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state";
@@ -79,7 +79,9 @@ describe("Private Room paywall navigation", () => {
renderHarness(root, "guest", roomDispatch);
expect(mocks.openAuth).toHaveBeenCalledWith(ROUTES.privateRoom);
expect(mocks.openAuth).toHaveBeenCalledWith(
getCharacterRoutes("elio").privateRoom,
);
expect(mocks.openSubscription).not.toHaveBeenCalled();
});
});
+2 -4
View File
@@ -1,11 +1,9 @@
import type { ReactNode } from "react";
import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider";
export default function PrivateRoomLayout({
export default function LegacyPrivateRoomLayout({
children,
}: {
children: ReactNode;
}) {
return <PrivateRoomRouteProvider>{children}</PrivateRoomRouteProvider>;
return children;
}
+19 -3
View File
@@ -1,5 +1,21 @@
import { PrivateRoomScreen } from "./private-room-screen";
import { redirect } from "next/navigation";
export default function PrivateRoomPage() {
return <PrivateRoomScreen />;
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function PrivateRoomPage({
searchParams,
}: {
searchParams: Promise<RouteSearchParams>;
}) {
redirect(
appendRouteSearchParams(
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).privateRoom,
await searchParams,
),
);
}
@@ -15,20 +15,22 @@ export function getPrivateAlbumGalleryState(input: {
export function buildPrivateAlbumGalleryUrl(
albumId: string,
imageIndex: number,
): `/private-room?${string}` {
basePath: string = ROUTES.privateRoom,
): string {
const params = new URLSearchParams({
[PRIVATE_ALBUM_QUERY_PARAM]: albumId,
[PRIVATE_ALBUM_IMAGE_QUERY_PARAM]: String(imageIndex),
});
return `${ROUTES.privateRoom}?${params.toString()}` as const;
return `${basePath}?${params.toString()}`;
}
export function buildPrivateRoomWithoutGalleryUrl(input: {
toString: () => string;
}): string {
export function buildPrivateRoomWithoutGalleryUrl(
input: { toString: () => string },
basePath: string = ROUTES.privateRoom,
): string {
const params = new URLSearchParams(input.toString());
params.delete(PRIVATE_ALBUM_QUERY_PARAM);
params.delete(PRIVATE_ALBUM_IMAGE_QUERY_PARAM);
const query = params.toString();
return query ? `${ROUTES.privateRoom}?${query}` : ROUTES.privateRoom;
return query ? `${basePath}?${query}` : basePath;
}
+47 -24
View File
@@ -6,8 +6,11 @@ import { useRouter, useSearchParams } from "next/navigation";
import { CharacterAvatar } from "@/app/_components";
import { AppBottomNav, MobileShell } from "@/app/_components/core";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { isPrivateAlbumLocked } from "@/lib/private-room/private_album";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import {
@@ -34,17 +37,12 @@ import {
getPrivateAlbumGalleryState,
} from "./private-album-gallery-url";
const FALLBACK_PROFILE = {
name: "Elio Silvestri",
avatar: "/images/avatar/elio.png",
title: "Elio Private room",
subtitle: "Join me, unlock my private room",
} as const;
export function PrivateRoomScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const navigator = useAppNavigator();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const state = usePrivateRoomState();
@@ -70,10 +68,10 @@ export function PrivateRoomScreen() {
});
usePrivateRoomUnlockSuccessRefresh(state.unlockSuccessNonce);
const displayName = FALLBACK_PROFILE.name;
const avatarUrl = FALLBACK_PROFILE.avatar;
const title = FALLBACK_PROFILE.title;
const subtitle = FALLBACK_PROFILE.subtitle;
const displayName = character.displayName;
const avatarUrl = character.assets.avatar;
const title = character.copy.privateRoomTitle;
const subtitle = character.copy.privateRoomSubtitle;
const pendingAlbum = useMemo(
() =>
state.items.find(
@@ -98,15 +96,26 @@ export function PrivateRoomScreen() {
isPrivateAlbumLocked(galleryAlbum) ||
!image?.url
) {
router.replace(buildPrivateRoomWithoutGalleryUrl(searchParams), {
scroll: false,
});
router.replace(
buildPrivateRoomWithoutGalleryUrl(
searchParams,
characterRoutes.privateRoom,
),
{ scroll: false },
);
}
}, [galleryAlbum, galleryState, router, searchParams, state.isLoading]);
}, [
characterRoutes.privateRoom,
galleryAlbum,
galleryState,
router,
searchParams,
state.isLoading,
]);
const handleTopUpClick = () => {
if (isPrivateRoomAuthRequired(authState.loginStatus)) {
navigator.openAuth(ROUTES.privateRoom);
navigator.openAuth(characterRoutes.privateRoom);
return;
}
navigator.openSubscription({
@@ -121,7 +130,10 @@ export function PrivateRoomScreen() {
const handleOpenGallery = (albumId: string) => {
openedGalleryInPageRef.current = true;
router.push(buildPrivateAlbumGalleryUrl(albumId, 0), { scroll: false });
router.push(
buildPrivateAlbumGalleryUrl(albumId, 0, characterRoutes.privateRoom),
{ scroll: false },
);
};
const handleCloseGallery = () => {
@@ -130,15 +142,23 @@ export function PrivateRoomScreen() {
router.back();
return;
}
router.replace(buildPrivateRoomWithoutGalleryUrl(searchParams), {
scroll: false,
});
router.replace(
buildPrivateRoomWithoutGalleryUrl(
searchParams,
characterRoutes.privateRoom,
),
{ scroll: false },
);
};
const handleGalleryIndexChange = (imageIndex: number) => {
if (!galleryAlbum) return;
router.replace(
buildPrivateAlbumGalleryUrl(galleryAlbum.albumId, imageIndex),
buildPrivateAlbumGalleryUrl(
galleryAlbum.albumId,
imageIndex,
characterRoutes.privateRoom,
),
{ scroll: false },
);
};
@@ -152,7 +172,7 @@ export function PrivateRoomScreen() {
<section className={styles.hero} aria-label={title}>
<div className={styles.heroCover}>
<Image
src="/images/private-room/banner/elio.png"
src={character.assets.privateRoomBanner}
alt=""
width={2048}
height={1152}
@@ -241,7 +261,10 @@ export function PrivateRoomScreen() {
<AppBottomNav
activeItem="privateRoom"
onChatClick={() => navigator.push(ROUTES.splash, { scroll: false })}
privateRoomLabel={character.copy.privateRoomTitle}
onChatClick={() =>
navigator.push(characterRoutes.splash, { scroll: false })
}
onPrivateRoomClick={() => undefined}
/>
+10 -3
View File
@@ -5,7 +5,7 @@ import { type Dispatch, useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/schemas/auth";
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
import { behaviorAnalytics } from "@/lib/analytics";
import { ROUTES } from "@/router/routes";
import { useActiveCharacterRoutes } from "@/providers/character-provider";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { AuthEvent } from "@/stores/auth/auth-events";
import type { PrivateRoomEvent } from "@/stores/private-room";
@@ -78,12 +78,13 @@ export function usePrivateRoomUnlockPaywallNavigation({
unlockPaywallRequest,
}: UsePrivateRoomUnlockPaywallNavigationInput): void {
const navigator = useAppNavigator();
const characterRoutes = useActiveCharacterRoutes();
useEffect(() => {
if (!unlockPaywallRequest) return;
if (isPrivateRoomAuthRequired(loginStatus)) {
navigator.openAuth(ROUTES.privateRoom);
navigator.openAuth(characterRoutes.privateRoom);
} else {
behaviorAnalytics.paywallShown({
entryPoint: "private_album_unlock",
@@ -99,7 +100,13 @@ export function usePrivateRoomUnlockPaywallNavigation({
});
}
roomDispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
}, [loginStatus, navigator, roomDispatch, unlockPaywallRequest]);
}, [
characterRoutes.privateRoom,
loginStatus,
navigator,
roomDispatch,
unlockPaywallRequest,
]);
}
export function usePrivateRoomUnlockSuccessRefresh(