Compare commits

...

3 Commits

54 changed files with 753 additions and 591 deletions
@@ -153,35 +153,6 @@ Authorization: Bearer <TOKEN>
费用、锁消息数量、成功解锁数量及 `messageIds` 都只计算当前角色。钱包余额仍是账号全局余额。 费用、锁消息数量、成功解锁数量及 `messageIds` 都只计算当前角色。钱包余额仍是账号全局余额。
## 9. Guest History Sync
```http
POST <API_BASE_URL>/api/chat/sync
Content-Type: application/json
Authorization: Bearer <LOGIN_TOKEN>
```
```json
{
"characterId": "maya-tan",
"messages": [
{"role": "user", "content": "Hello", "timestamp": "2026-07-17T09:00:00Z"},
{"role": "assistant", "content": "Hi", "timestamp": "2026-07-17T09:00:01Z"}
]
}
```
每次同步只能包含一个角色。本地若保存了多个角色,前端按角色分组分别调用。
若页面使用用户聊天统计,也必须带角色:
```http
GET <API_BASE_URL>/api/user/stats?characterId=maya-tan
```
其中 `totalMessages`、近期记忆、亲密度、情绪和关系状态都按角色返回。
响应会明确带上 `characterId``relationshipStage``currentMood`,前端不要把这些状态写回其他角色的本地缓存。
## 12. Tip Attribution ## 12. Tip Attribution
只有 Tip 订单使用角色归属: 只有 Tip 订单使用角色归属:
@@ -196,14 +167,3 @@ GET <API_BASE_URL>/api/user/stats?characterId=maya-tan
``` ```
Tip 订单必须携带 `recipientCharacterId`。VIP 和积分充值会忽略该字段。 Tip 订单必须携带 `recipientCharacterId`。VIP 和积分充值会忽略该字段。
## 13. Failures
| HTTP | `detail.errorCode` | Meaning | Frontend action |
| ---: | --- | --- | --- |
| 403 | `CHARACTER_DISABLED` | 角色或对应能力未开放 | 禁用输入并刷新角色目录 |
| 404 | `CHARACTER_NOT_FOUND` | 角色 ID 不存在 | 清理本地旧角色 ID,回角色列表 |
| 409 | `CHARACTER_MISMATCH` | 消息或双参数属于不同角色 | 不重试、不扣费,刷新当前角色历史 |
| 503 | `CHARACTER_STATE_UNAVAILABLE` | 角色会话状态暂不可用 | 保留输入并稍后重试 |
新前端的角色业务请求必须显式携带角色 ID。应用入口未指定角色时选择 Elio;显式错误、停用或不匹配的角色不会回退 Elio。
+5
View File
@@ -2,9 +2,14 @@ import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import { withSerwist } from "@serwist/turbopack"; import { withSerwist } from "@serwist/turbopack";
import { LEGACY_GLOBAL_ROUTE_REDIRECTS } from "./src/router/legacy-global-route-redirects";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Docker runs the traced runtime emitted to `.next/standalone`. // Docker runs the traced runtime emitted to `.next/standalone`.
output: "standalone", output: "standalone",
async redirects() {
return [...LEGACY_GLOBAL_ROUTE_REDIRECTS];
},
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
@@ -1,5 +0,0 @@
import { CoinsRulesScreen } from "@/app/coins-rules/coins-rules-screen";
export default function CharacterCoinsRulesPage() {
return <CoinsRulesScreen />;
}
@@ -1,5 +0,0 @@
import { FeedbackScreen } from "@/app/feedback/feedback-screen";
export default function CharacterFeedbackPage() {
return <FeedbackScreen />;
}
@@ -1,5 +0,0 @@
import { SidebarScreen } from "@/app/sidebar/sidebar-screen";
export default function CharacterSidebarPage() {
return <SidebarScreen />;
}
+7 -1
View File
@@ -10,6 +10,8 @@ import { Lock, Menu } from "lucide-react";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { useActiveCharacterRoutes } from "@/providers/character-provider"; import { useActiveCharacterRoutes } from "@/providers/character-provider";
import { buildGlobalPageUrl } from "@/router/global-route-context";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { BrowserHintOverlay } from "./browser-hint-overlay"; import { BrowserHintOverlay } from "./browser-hint-overlay";
@@ -75,7 +77,11 @@ export function ChatHeader({
data-analytics-key="chat.open_menu" data-analytics-key="chat.open_menu"
data-analytics-label="Open chat menu" data-analytics-label="Open chat menu"
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]" className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={() => navigator.push(characterRoutes.sidebar)} onClick={() =>
navigator.push(
buildGlobalPageUrl(ROUTES.sidebar, characterRoutes.chat),
)
}
aria-label="Menu" aria-label="Menu"
> >
<Menu size={24} aria-hidden="true" /> <Menu size={24} aria-hidden="true" />
@@ -0,0 +1,35 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { CoinsRulesScreen } from "../coins-rules-screen";
vi.mock("@/stores/auth/auth-context", () => ({
useAuthState: () => ({
hasInitialized: true,
isLoading: false,
loginStatus: "email",
}),
}));
vi.mock("@/stores/user/user-context", () => ({
useUserDispatch: () => vi.fn(),
useUserState: () => ({
currentUser: {
dailyFreeChatLimit: 30,
dailyFreePrivateLimit: 2,
},
}),
}));
describe("CoinsRulesScreen", () => {
it("renders outside CharacterProvider and returns to global sidebar", () => {
const html = renderToStaticMarkup(
<CoinsRulesScreen returnTo="/characters/maya/chat" />,
);
expect(html).toContain("Coin Usage Rules");
expect(html).toContain(
'href="/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
);
});
});
+11 -4
View File
@@ -12,7 +12,10 @@ import { FaCoins } from "react-icons/fa6";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { useActiveCharacterRoutes } from "@/providers/character-provider"; import {
resolveGlobalRouteContext,
type GlobalReturnToValue,
} from "@/router/global-route-context";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { useUserDispatch, useUserState } from "@/stores/user/user-context";
@@ -75,8 +78,12 @@ const createCoinRules = (
}, },
] as const; ] as const;
export function CoinsRulesScreen() { export interface CoinsRulesScreenProps {
const characterRoutes = useActiveCharacterRoutes(); returnTo?: GlobalReturnToValue;
}
export function CoinsRulesScreen({ returnTo }: CoinsRulesScreenProps) {
const navigation = resolveGlobalRouteContext(returnTo);
const authState = useAuthState(); const authState = useAuthState();
const userState = useUserState(); const userState = useUserState();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
@@ -111,7 +118,7 @@ export function CoinsRulesScreen() {
<main className={styles.screen}> <main className={styles.screen}>
<header className={styles.header}> <header className={styles.header}>
<BackButton <BackButton
href={characterRoutes.sidebar} href={navigation.sidebarUrl}
variant="soft" variant="soft"
aria-label="Back to sidebar" aria-label="Back to sidebar"
/> />
+4 -13
View File
@@ -1,21 +1,12 @@
import { redirect } from "next/navigation"; import type { RouteSearchParams } from "@/router/routes";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; import { CoinsRulesScreen } from "./coins-rules-screen";
import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function CoinsRulesPage({ export default async function CoinsRulesPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<RouteSearchParams>; searchParams: Promise<RouteSearchParams>;
}) { }) {
redirect( const query = await searchParams;
appendRouteSearchParams( return <CoinsRulesScreen returnTo={query.returnTo} />;
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).coinsRules,
await searchParams,
),
);
} }
@@ -2,8 +2,6 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCharacterBySlug } from "@/data/constants/character";
import { CharacterProvider } from "@/providers/character-provider";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { FeedbackScreen } from "../feedback-screen"; import { FeedbackScreen } from "../feedback-screen";
@@ -13,8 +11,8 @@ const mocks = vi.hoisted(() => ({
submitFeedback: vi.fn(), submitFeedback: vi.fn(),
})); }));
vi.mock("@/router/use-app-navigator", () => ({ vi.mock("@/router/use-global-app-navigator", () => ({
useAppNavigator: () => ({ replace: mocks.replace }), useGlobalAppNavigator: () => ({ replace: mocks.replace }),
})); }));
vi.mock("@/lib/feedback", () => ({ vi.mock("@/lib/feedback", () => ({
@@ -69,6 +67,11 @@ describe("FeedbackScreen", () => {
.querySelector("textarea") .querySelector("textarea")
?.getAttribute("aria-describedby"), ?.getAttribute("aria-describedby"),
).toBe("feedback-content-hint"); ).toBe("feedback-content-hint");
expect(
container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="feedback.back_to_sidebar"]',
)?.getAttribute("href"),
).toBe("/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat");
}); });
it("submits valid text and renders the feedback id", async () => { it("submits valid text and renders the feedback id", async () => {
@@ -103,18 +106,13 @@ describe("FeedbackScreen", () => {
}); });
expect(container.textContent).toContain("feedback-123"); expect(container.textContent).toContain("feedback-123");
expect(container.textContent).toContain("Thank you for helping us."); expect(container.textContent).toContain("Thank you for helping us.");
expect(container.textContent).toContain("Back to Sidebar");
}); });
}); });
function renderFeedbackScreen(root: Root): void { function renderFeedbackScreen(root: Root): void {
const character = getCharacterBySlug("maya");
if (!character) throw new Error("Missing Maya character fixture");
act(() => act(() =>
root.render( root.render(<FeedbackScreen returnTo="/characters/maya/chat" />),
<CharacterProvider character={character}>
<FeedbackScreen />
</CharacterProvider>,
),
); );
} }
+18 -11
View File
@@ -17,8 +17,11 @@ import {
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import type { FeedbackCategory } from "@/data/schemas/feedback"; import type { FeedbackCategory } from "@/data/schemas/feedback";
import { useActiveCharacterRoutes } from "@/providers/character-provider"; import {
import { useAppNavigator } from "@/router/use-app-navigator"; resolveGlobalRouteContext,
type GlobalReturnToValue,
} from "@/router/global-route-context";
import { useGlobalAppNavigator } from "@/router/use-global-app-navigator";
import { import {
FEEDBACK_IMAGE_ACCEPT, FEEDBACK_IMAGE_ACCEPT,
@@ -45,9 +48,13 @@ const CATEGORY_OPTIONS: readonly CategoryOption[] = [
{ value: "other", label: "Other", icon: MessageCircleMore }, { value: "other", label: "Other", icon: MessageCircleMore },
]; ];
export function FeedbackScreen() { export interface FeedbackScreenProps {
const navigator = useAppNavigator(); returnTo?: GlobalReturnToValue;
const characterRoutes = useActiveCharacterRoutes(); }
export function FeedbackScreen({ returnTo }: FeedbackScreenProps) {
const navigator = useGlobalAppNavigator();
const navigation = resolveGlobalRouteContext(returnTo);
const form = useFeedbackSubmission(); const form = useFeedbackSubmission();
if (form.feedbackId) { if (form.feedbackId) {
@@ -70,11 +77,11 @@ export function FeedbackScreen() {
</div> </div>
<button <button
type="button" type="button"
data-analytics-key="feedback.back_to_chat" data-analytics-key="feedback.back_to_sidebar"
className={styles.primaryButton} className={styles.primaryButton}
onClick={() => navigator.replace(characterRoutes.chat)} onClick={() => navigator.replace(navigation.sidebarUrl)}
> >
Back to Chat Back to Sidebar
</button> </button>
</section> </section>
</main> </main>
@@ -95,10 +102,10 @@ export function FeedbackScreen() {
<header className={styles.header}> <header className={styles.header}>
<BackButton <BackButton
href={characterRoutes.chat} href={navigation.sidebarUrl}
variant="soft" variant="soft"
ariaLabel="Back to chat" ariaLabel="Back to sidebar"
analyticsKey="feedback.back_to_chat" analyticsKey="feedback.back_to_sidebar"
/> />
<div className={styles.headingBlock}> <div className={styles.headingBlock}>
<h1 className={styles.title}>Help us improve CozSweet</h1> <h1 className={styles.title}>Help us improve CozSweet</h1>
+4 -13
View File
@@ -1,21 +1,12 @@
import { redirect } from "next/navigation"; import type { RouteSearchParams } from "@/router/routes";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; import { FeedbackScreen } from "./feedback-screen";
import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function FeedbackPage({ export default async function FeedbackPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<RouteSearchParams>; searchParams: Promise<RouteSearchParams>;
}) { }) {
redirect( const query = await searchParams;
appendRouteSearchParams( return <FeedbackScreen returnTo={query.returnTo} />;
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).feedback,
await searchParams,
),
);
} }
@@ -0,0 +1,122 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarScreen } from "../sidebar-screen";
const mocks = vi.hoisted(() => ({
openAuth: vi.fn(),
openSubscription: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
authDispatch: vi.fn(),
userDispatch: vi.fn(),
}));
vi.mock("@/router/use-global-app-navigator", () => ({
useGlobalAppNavigator: () => ({
openAuth: mocks.openAuth,
openSubscription: mocks.openSubscription,
push: mocks.push,
replace: mocks.replace,
}),
}));
vi.mock("@/stores/auth/auth-context", () => ({
useAuthDispatch: () => mocks.authDispatch,
useAuthState: () => ({
hasInitialized: true,
isLoading: false,
loginStatus: "email",
}),
}));
vi.mock("@/stores/user/user-context", () => ({
useUserDispatch: () => mocks.userDispatch,
useUserState: () => ({
avatarUrl: null,
creditBalance: 120,
currentUser: {
id: "user-1",
username: "Chase",
countryCode: "US",
creditBalance: 120,
dailyFreeChatLimit: 30,
dailyFreeChatRemaining: 20,
dailyFreePrivateLimit: 2,
dailyFreePrivateRemaining: 1,
isVip: false,
},
}),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
vi.mock("../use-pwa-install-entry", () => ({
usePwaInstallEntry: () => ({
canInstall: false,
installApp: vi.fn(),
}),
}));
vi.mock("../use-sidebar-user-bootstrap", () => ({
useSidebarUserBootstrap: vi.fn(),
}));
describe("SidebarScreen", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
Object.values(mocks).forEach((mock) => mock.mockReset());
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders without CharacterProvider and preserves the source character", () => {
act(() => {
root.render(<SidebarScreen returnTo="/characters/maya/chat" />);
});
expect(
container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="sidebar.back_to_chat"]',
)?.getAttribute("href"),
).toBe("/characters/maya/chat");
clickButton("Coin Usage Rules");
expect(mocks.push).toHaveBeenCalledWith(
"/coins-rules?returnTo=%2Fcharacters%2Fmaya%2Fchat",
);
clickButton("Feedback");
expect(mocks.push).toHaveBeenCalledWith(
"/feedback?returnTo=%2Fcharacters%2Fmaya%2Fchat",
);
clickButton("Top Up");
expect(mocks.openSubscription).toHaveBeenCalledWith(
expect.objectContaining({
type: "topup",
sourceCharacterSlug: "maya",
returnTo: "sidebar",
}),
);
});
function clickButton(label: string): void {
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes(label),
);
if (!button) throw new Error(`Missing ${label} button`);
act(() => button.click());
}
});
+4 -13
View File
@@ -1,21 +1,12 @@
import { redirect } from "next/navigation"; import type { RouteSearchParams } from "@/router/routes";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; import { SidebarScreen } from "./sidebar-screen";
import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function SidebarPage({ export default async function SidebarPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<RouteSearchParams>; searchParams: Promise<RouteSearchParams>;
}) { }) {
redirect( const query = await searchParams;
appendRouteSearchParams( return <SidebarScreen returnTo={query.returnTo} />;
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).sidebar,
await searchParams,
),
);
} }
+21 -10
View File
@@ -5,8 +5,11 @@ import { signOut } from "next-auth/react";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { useAppNavigator } from "@/router/use-app-navigator"; import {
import { useActiveCharacterRoutes } from "@/providers/character-provider"; 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 { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-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"; import styles from "./components/sidebar-screen.module.css";
export function SidebarScreen() { export interface SidebarScreenProps {
const navigator = useAppNavigator(); returnTo?: GlobalReturnToValue;
const characterRoutes = useActiveCharacterRoutes(); }
export function SidebarScreen({ returnTo }: SidebarScreenProps) {
const navigator = useGlobalAppNavigator();
const navigation = resolveGlobalRouteContext(returnTo);
const user = useUserState(); const user = useUserState();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const auth = useAuthState(); const auth = useAuthState();
@@ -32,7 +39,7 @@ export function SidebarScreen() {
userDispatch({ type: "UserClearLocal" }); userDispatch({ type: "UserClearLocal" });
authDispatch({ type: "AuthLogoutSubmitted" }); authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false }); void signOut({ redirect: false });
navigator.replace(characterRoutes.splash, { scroll: false }); navigator.replace(navigation.splashUrl, { scroll: false });
}; };
const view = getSidebarViewModel({ const view = getSidebarViewModel({
@@ -48,7 +55,7 @@ export function SidebarScreen() {
<div className={styles.topBar}> <div className={styles.topBar}>
<BackButton <BackButton
href={characterRoutes.chat} href={navigation.chatUrl}
variant="soft" variant="soft"
analyticsKey="sidebar.back_to_chat" analyticsKey="sidebar.back_to_chat"
/> />
@@ -60,7 +67,7 @@ export function SidebarScreen() {
name={view.name} name={view.name}
avatarUrl={view.avatarUrl} avatarUrl={view.avatarUrl}
isLoading={view.isInitializingUser} isLoading={view.isInitializingUser}
onLoginClick={() => navigator.openAuth(characterRoutes.sidebar)} onLoginClick={() => navigator.openAuth(navigation.sidebarUrl)}
/> />
</section> </section>
@@ -76,6 +83,8 @@ export function SidebarScreen() {
? () => ? () =>
navigator.openSubscription({ navigator.openSubscription({
type: "vip", type: "vip",
sourceCharacterSlug: navigation.characterSlug,
returnTo: "sidebar",
analytics: { analytics: {
entryPoint: "sidebar", entryPoint: "sidebar",
triggerReason: "vip_cta", triggerReason: "vip_cta",
@@ -86,13 +95,15 @@ export function SidebarScreen() {
onTopUp={() => onTopUp={() =>
navigator.openSubscription({ navigator.openSubscription({
type: "topup", type: "topup",
sourceCharacterSlug: navigation.characterSlug,
returnTo: "sidebar",
analytics: { analytics: {
entryPoint: "sidebar", entryPoint: "sidebar",
triggerReason: "sidebar_recharge", triggerReason: "sidebar_recharge",
}, },
}) })
} }
onRulesClick={() => navigator.push(characterRoutes.coinsRules)} onRulesClick={() => navigator.push(navigation.coinsRulesUrl)}
/> />
</section> </section>
@@ -105,7 +116,7 @@ export function SidebarScreen() {
data-analytics-key="sidebar.open_feedback" data-analytics-key="sidebar.open_feedback"
data-analytics-label="Open feedback" data-analytics-label="Open feedback"
className={`${styles.logoutCard} ${styles.feedbackCard}`} className={`${styles.logoutCard} ${styles.feedbackCard}`}
onClick={() => navigator.push(characterRoutes.feedback)} onClick={() => navigator.push(navigation.feedbackUrl)}
> >
<span <span
className={`${styles.logoutIcon} ${styles.feedbackIcon}`} className={`${styles.logoutIcon} ${styles.feedbackIcon}`}
@@ -0,0 +1,47 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
screenProps: vi.fn(),
}));
vi.mock("../subscription-screen", () => ({
SubscriptionScreen: (props: Record<string, unknown>) => {
mocks.screenProps(props);
return <main>Global subscription</main>;
},
}));
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" }),
);
});
});
@@ -23,14 +23,14 @@ export interface SubscriptionCheckoutButtonProps {
disabled?: boolean; disabled?: boolean;
subscriptionType: "vip" | "topup"; subscriptionType: "vip" | "topup";
returnTo?: SubscriptionReturnTo; returnTo?: SubscriptionReturnTo;
characterSlug?: string; sourceCharacterSlug?: string;
} }
export function SubscriptionCheckoutButton({ export function SubscriptionCheckoutButton({
disabled = false, disabled = false,
subscriptionType, subscriptionType,
returnTo = null, returnTo = null,
characterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
}: SubscriptionCheckoutButtonProps) { }: SubscriptionCheckoutButtonProps) {
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
@@ -40,7 +40,7 @@ export function SubscriptionCheckoutButton({
payment, payment,
paymentDispatch, paymentDispatch,
returnTo: returnTo ?? undefined, returnTo: returnTo ?? undefined,
characterSlug, characterSlug: sourceCharacterSlug,
subscriptionType, subscriptionType,
}); });
+2 -2
View File
@@ -29,7 +29,7 @@ export default async function SubscriptionPage({
const subscriptionType = toSubscriptionType( const subscriptionType = toSubscriptionType(
getFirstPaymentSearchParam(query.type), getFirstPaymentSearchParam(query.type),
); );
const characterSlug = const sourceCharacterSlug =
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
DEFAULT_CHARACTER_SLUG; DEFAULT_CHARACTER_SLUG;
const analyticsContext = parsePaymentAnalyticsContext({ const analyticsContext = parsePaymentAnalyticsContext({
@@ -49,7 +49,7 @@ export default async function SubscriptionPage({
returnTo={parseSubscriptionReturnTo(query.returnTo)} returnTo={parseSubscriptionReturnTo(query.returnTo)}
initialPayChannel={paymentReturn.initialPayChannel} initialPayChannel={paymentReturn.initialPayChannel}
analyticsContext={analyticsContext} analyticsContext={analyticsContext}
characterSlug={characterSlug} sourceCharacterSlug={sourceCharacterSlug}
/> />
); );
} }
+4 -4
View File
@@ -43,7 +43,7 @@ export interface SubscriptionScreenProps {
returnTo?: SubscriptionReturnTo; returnTo?: SubscriptionReturnTo;
initialPayChannel?: PayChannel | null; initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext; analyticsContext?: PaymentAnalyticsContext;
characterSlug?: string; sourceCharacterSlug?: string;
} }
export function SubscriptionScreen({ export function SubscriptionScreen({
@@ -52,7 +52,7 @@ export function SubscriptionScreen({
returnTo = null, returnTo = null,
initialPayChannel = null, initialPayChannel = null,
analyticsContext: providedAnalyticsContext, analyticsContext: providedAnalyticsContext,
characterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
}: SubscriptionScreenProps) { }: SubscriptionScreenProps) {
const userState = useUserState(); const userState = useUserState();
const countryCode = userState.currentUser?.countryCode; const countryCode = userState.currentUser?.countryCode;
@@ -70,7 +70,7 @@ export function SubscriptionScreen({
subscriptionType, subscriptionType,
shouldResumePendingOrder, shouldResumePendingOrder,
returnTo, returnTo,
characterSlug, sourceCharacterSlug,
initialPayChannel: paymentMethodConfig.initialPayChannel, initialPayChannel: paymentMethodConfig.initialPayChannel,
}); });
const canSubscribeVip = subscriptionType === "vip"; const canSubscribeVip = subscriptionType === "vip";
@@ -242,7 +242,7 @@ export function SubscriptionScreen({
disabled={!canActivate} disabled={!canActivate}
subscriptionType={subscriptionType} subscriptionType={subscriptionType}
returnTo={returnTo} returnTo={returnTo}
characterSlug={characterSlug} sourceCharacterSlug={sourceCharacterSlug}
/> />
</div> </div>
@@ -19,7 +19,7 @@ export interface UseSubscriptionPaymentFlowInput {
shouldResumePendingOrder: boolean; shouldResumePendingOrder: boolean;
returnTo: SubscriptionReturnTo; returnTo: SubscriptionReturnTo;
initialPayChannel: PayChannel; initialPayChannel: PayChannel;
characterSlug?: string; sourceCharacterSlug?: string;
} }
export function useSubscriptionPaymentFlow({ export function useSubscriptionPaymentFlow({
@@ -27,7 +27,7 @@ export function useSubscriptionPaymentFlow({
shouldResumePendingOrder, shouldResumePendingOrder,
returnTo, returnTo,
initialPayChannel, initialPayChannel,
characterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
}: UseSubscriptionPaymentFlowInput) { }: UseSubscriptionPaymentFlowInput) {
const router = useRouter(); const router = useRouter();
const { payment, paymentDispatch } = usePaymentRouteFlow({ const { payment, paymentDispatch } = usePaymentRouteFlow({
@@ -50,7 +50,7 @@ export function useSubscriptionPaymentFlow({
const handleBackClick = () => { const handleBackClick = () => {
void (async () => { void (async () => {
router.replace( router.replace(
await consumeSubscriptionExitUrl(returnTo, characterSlug), await consumeSubscriptionExitUrl(returnTo, sourceCharacterSlug),
); );
})(); })();
}; };
@@ -60,7 +60,10 @@ export function useSubscriptionPaymentFlow({
paymentDispatch({ type: "PaymentReset" }); paymentDispatch({ type: "PaymentReset" });
void (async () => { void (async () => {
router.replace( router.replace(
await resolveSubscriptionSuccessExitUrl(returnTo, characterSlug), await resolveSubscriptionSuccessExitUrl(
returnTo,
sourceCharacterSlug,
),
); );
})(); })();
}; };
@@ -1,15 +0,0 @@
{
"characterId": "elio",
"messages": [
{
"role": "user",
"content": "Hi Elio.",
"timestamp": "2026-06-23T02:10:00.000Z"
},
{
"role": "assistant",
"content": "Hi, I am right here with you.",
"timestamp": "2026-06-23T02:10:04.000Z"
}
]
}
@@ -6,8 +6,6 @@ import type {
import { import {
ChatHistoryResponse, ChatHistoryResponse,
ChatPreviewsResponse, ChatPreviewsResponse,
ChatSyncRequest,
ChatSyncRequestSchema,
ChatSendResponse, ChatSendResponse,
SendMessageRequestSchema, SendMessageRequestSchema,
UnlockHistoryRequestSchema, UnlockHistoryRequestSchema,
@@ -58,15 +56,6 @@ export class ChatRemoteDataSource {
return Result.wrap(() => this.api.getPreviews(options)); return Result.wrap(() => this.api.getPreviews(options));
} }
async syncGuestHistory(
request: ChatSyncRequest,
options?: ChatRequestOptions,
): Promise<Result<void>> {
return Result.wrap(() =>
this.api.syncGuestHistory(ChatSyncRequestSchema.parse(request), options),
);
}
async getHistory( async getHistory(
characterId: string, characterId: string,
limit = 50, limit = 50,
-8
View File
@@ -10,7 +10,6 @@ import type {
import type { import type {
ChatHistoryResponse, ChatHistoryResponse,
ChatPreviewsResponse, ChatPreviewsResponse,
ChatSyncRequest,
ChatMessage, ChatMessage,
ChatSendResponse, ChatSendResponse,
UnlockHistoryResponse, UnlockHistoryResponse,
@@ -63,13 +62,6 @@ export class ChatRepository implements IChatRepository {
return this.remote.getPreviews(options); return this.remote.getPreviews(options);
} }
async syncGuestHistory(
request: ChatSyncRequest,
options?: ChatRequestOptions,
): Promise<Result<void>> {
return this.remote.syncGuestHistory(request, options);
}
/** 解锁单条历史付费 / 私密消息。 */ /** 解锁单条历史付费 / 私密消息。 */
async unlockPrivateMessage( async unlockPrivateMessage(
input: UnlockPrivateMessageInput, input: UnlockPrivateMessageInput,
@@ -7,7 +7,6 @@
import type { import type {
ChatHistoryResponse, ChatHistoryResponse,
ChatPreviewsResponse, ChatPreviewsResponse,
ChatSyncRequest,
ChatImageData, ChatImageData,
ChatLockDetailData, ChatLockDetailData,
ChatLockType, ChatLockType,
@@ -79,12 +78,6 @@ export interface IChatRepository {
options?: ChatRequestOptions, options?: ChatRequestOptions,
): Promise<Result<ChatPreviewsResponse>>; ): Promise<Result<ChatPreviewsResponse>>;
/** 把一个角色的游客历史同步到正式账号。 */
syncGuestHistory(
request: ChatSyncRequest,
options?: ChatRequestOptions,
): Promise<Result<void>>;
/** 解锁单条历史付费 / 私密消息。 */ /** 解锁单条历史付费 / 私密消息。 */
unlockPrivateMessage( unlockPrivateMessage(
input: UnlockPrivateMessageInput, input: UnlockPrivateMessageInput,
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { import {
ChatPreviewsResponseSchema, ChatPreviewsResponseSchema,
ChatSyncRequestSchema,
SendMessageRequestSchema, SendMessageRequestSchema,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
@@ -26,23 +25,12 @@ describe("multi-role chat schemas", () => {
).toThrow(); ).toThrow();
}); });
it("parses nullable previews and immutable sync messages", () => { it("parses nullable immutable previews", () => {
const previews = ChatPreviewsResponseSchema.parse({ const previews = ChatPreviewsResponseSchema.parse({
items: [{ characterId: "elio", message: null }], items: [{ characterId: "elio", message: null }],
}); });
const sync = ChatSyncRequestSchema.parse({
characterId: "elio",
messages: [
{
role: "assistant",
content: "Welcome back",
timestamp: "2026-07-20T00:00:00.000Z",
},
],
});
expect(previews.items[0]?.message).toBeNull(); expect(previews.items[0]?.message).toBeNull();
expect(Object.isFrozen(previews.items)).toBe(true); expect(Object.isFrozen(previews.items)).toBe(true);
expect(Object.isFrozen(sync.messages)).toBe(true);
}); });
}); });
-1
View File
@@ -7,7 +7,6 @@ export * from "./chat_media";
export * from "./chat_message"; export * from "./chat_message";
export * from "./chat_payloads"; export * from "./chat_payloads";
export * from "./request/send_message_request"; export * from "./request/send_message_request";
export * from "./request/chat_sync_request";
export * from "./request/unlock_history_request"; export * from "./request/unlock_history_request";
export * from "./request/unlock_private_request"; export * from "./request/unlock_private_request";
export * from "./response/chat_history_response"; export * from "./response/chat_history_response";
@@ -1,19 +0,0 @@
import { z } from "zod";
export const ChatSyncMessageSchema = z
.object({
role: z.enum(["user", "assistant"]),
content: z.string(),
timestamp: z.string().min(1),
})
.readonly();
export const ChatSyncRequestSchema = z
.object({
characterId: z.string().min(1),
messages: z.array(ChatSyncMessageSchema).min(1).readonly(),
})
.readonly();
export type ChatSyncRequestInput = z.input<typeof ChatSyncRequestSchema>;
export type ChatSyncRequest = z.output<typeof ChatSyncRequestSchema>;
-1
View File
@@ -3,6 +3,5 @@
*/ */
export * from "./send_message_request"; export * from "./send_message_request";
export * from "./chat_sync_request";
export * from "./unlock_private_request"; export * from "./unlock_private_request";
export * from "./unlock_history_request"; export * from "./unlock_history_request";
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
ChatSyncRequestSchema,
SendMessageRequestSchema, SendMessageRequestSchema,
UnlockHistoryRequestSchema, UnlockHistoryRequestSchema,
UnlockPrivateRequestSchema, UnlockPrivateRequestSchema,
@@ -74,30 +73,16 @@ describe("multi-character API contract", () => {
}); });
}); });
it("loads previews and syncs guest history", async () => { it("loads chat previews", async () => {
const api = new ChatApi(); const api = new ChatApi();
httpClientMock httpClientMock.mockResolvedValueOnce({
.mockResolvedValueOnce({ success: true, data: { items: [] } }) success: true,
.mockResolvedValueOnce({ success: true, data: {} }); data: { items: [] },
});
await api.getPreviews(); await api.getPreviews();
const request = ChatSyncRequestSchema.parse({
characterId: CHARACTER_ID,
messages: [
{
role: "user",
content: "Hello",
timestamp: "2026-07-20T00:00:00.000Z",
},
],
});
await api.syncGuestHistory(request);
expect(httpClientMock).toHaveBeenNthCalledWith(1, "/api/chat/previews", {}); expect(httpClientMock).toHaveBeenCalledWith("/api/chat/previews", {});
expect(httpClientMock).toHaveBeenNthCalledWith(2, "/api/chat/sync", {
method: "POST",
body: request,
});
}); });
it("forwards request cancellation to the HTTP client", async () => { it("forwards request cancellation to the HTTP client", async () => {
+1 -2
View File
@@ -26,6 +26,5 @@
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" }, "reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
"feedback": { "method": "post", "path": "/api/feedback" }, "feedback": { "method": "post", "path": "/api/feedback" },
"characters": { "method": "get", "path": "/api/characters" }, "characters": { "method": "get", "path": "/api/characters" },
"chatPreviews": { "method": "get", "path": "/api/chat/previews" }, "chatPreviews": { "method": "get", "path": "/api/chat/previews" }
"chatSync": { "method": "post", "path": "/api/chat/sync" }
} }
-2
View File
@@ -104,6 +104,4 @@ export class ApiPath {
static readonly chatPreviews = apiContract.chatPreviews.path; static readonly chatPreviews = apiContract.chatPreviews.path;
static readonly chatSync = apiContract.chatSync.path;
} }
-12
View File
@@ -11,7 +11,6 @@ import {
ChatPreviewsResponseSchema, ChatPreviewsResponseSchema,
ChatSendResponse, ChatSendResponse,
ChatSendResponseSchema, ChatSendResponseSchema,
ChatSyncRequest,
SendMessageRequest, SendMessageRequest,
UnlockHistoryRequest, UnlockHistoryRequest,
UnlockHistoryResponse, UnlockHistoryResponse,
@@ -67,17 +66,6 @@ export class ChatApi {
return ChatPreviewsResponseSchema.parse(unwrap(env)); return ChatPreviewsResponseSchema.parse(unwrap(env));
} }
async syncGuestHistory(
body: ChatSyncRequest,
options?: { signal?: AbortSignal },
): Promise<void> {
await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSync, {
method: "POST",
body,
...(options?.signal ? { signal: options.signal } : {}),
}).then(unwrap);
}
/** /**
* / * /
*/ */
@@ -259,22 +259,6 @@ export class NavigationStorage {
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
} }
static async saveGuestChatOwnerKey(ownerKey: string): Promise<void> {
await SessionAsyncUtil.setJson(
StorageKeys.guestChatOwnerKey,
ownerKey,
z.string().min(1),
);
}
static async getGuestChatOwnerKey(): Promise<string | null> {
const result = await SessionAsyncUtil.getJson(
StorageKeys.guestChatOwnerKey,
z.string().min(1),
);
return Result.isOk(result) ? result.data : null;
}
private static parsePendingChatUnlock( private static parsePendingChatUnlock(
value: PendingChatUnlock | null, value: PendingChatUnlock | null,
): PendingChatUnlock | null { ): PendingChatUnlock | null {
@@ -12,7 +12,7 @@ const PendingPaymentOrderSchema = z.object({
payChannel: z.literal("ezpay"), payChannel: z.literal("ezpay"),
subscriptionType: z.enum(["vip", "topup", "tip"]), subscriptionType: z.enum(["vip", "topup", "tip"]),
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(), 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(), characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
createdAt: z.number(), createdAt: z.number(),
}); });
-1
View File
@@ -28,7 +28,6 @@ export const StorageKeys = {
pendingChatImageReturn: "pending_chat_image_return", pendingChatImageReturn: "pending_chat_image_return",
pendingChatUnlock: "pending_chat_unlock", pendingChatUnlock: "pending_chat_unlock",
pendingChatPromotion: "pending_chat_promotion", pendingChatPromotion: "pending_chat_promotion",
guestChatOwnerKey: "guest_chat_owner_key",
// pwa / app info // pwa / app info
pwaDialogShown: "pwa_dialog_shown", pwaDialogShown: "pwa_dialog_shown",
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; 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 { consumePendingChatImageReturn } from "../chat_image_return_session";
import { import {
@@ -63,14 +64,31 @@ describe("subscription exit helpers", () => {
consumePendingChatImageReturnMock.mockResolvedValue(null); consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue(null); peekPendingChatUnlockMock.mockResolvedValue(null);
expect(getSubscriptionFallbackExitUrl(null)).toBe( expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
getCharacterRoutes("elio").sidebar,
);
await expect(consumeSubscriptionExitUrl(null)).resolves.toBe( 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 () => { it("returns directly to private room without consuming chat state", async () => {
consumePendingChatImageReturnMock.mockResolvedValue({ consumePendingChatImageReturnMock.mockResolvedValue({
reason: "image_paywall", reason: "image_paywall",
+12 -2
View File
@@ -5,7 +5,8 @@ import {
DEFAULT_CHARACTER_SLUG, DEFAULT_CHARACTER_SLUG,
getCharacterBySlug, getCharacterBySlug,
} from "@/data/constants/character"; } 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 type { AppSubscriptionReturnTo } from "@/router/navigation-types";
import { consumePendingChatImageReturn } from "./chat_image_return_session"; import { consumePendingChatImageReturn } from "./chat_image_return_session";
@@ -47,7 +48,10 @@ export function getSubscriptionFallbackExitUrl(
const routes = getCharacterRoutes(characterSlug); const routes = getCharacterRoutes(characterSlug);
if (returnTo === "chat") return routes.chat; if (returnTo === "chat") return routes.chat;
if (returnTo === "private-room") return routes.privateRoom; 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( export async function consumeSubscriptionExitUrl(
@@ -57,6 +61,9 @@ export async function consumeSubscriptionExitUrl(
if (returnTo === "private-room") { if (returnTo === "private-room") {
return getCharacterRoutes(characterSlug).privateRoom; return getCharacterRoutes(characterSlug).privateRoom;
} }
if (returnTo === "sidebar") {
return getSubscriptionFallbackExitUrl(returnTo, characterSlug);
}
return ( return (
(await consumeSubscriptionExplicitExitUrl(characterSlug)) ?? (await consumeSubscriptionExplicitExitUrl(characterSlug)) ??
@@ -71,6 +78,9 @@ export async function resolveSubscriptionSuccessExitUrl(
if (returnTo === "private-room") { if (returnTo === "private-room") {
return getCharacterRoutes(characterSlug).privateRoom; return getCharacterRoutes(characterSlug).privateRoom;
} }
if (returnTo === "sidebar") {
return getSubscriptionFallbackExitUrl(returnTo, characterSlug);
}
const pendingExitUrl = await peekSubscriptionExplicitExitUrl(characterSlug); const pendingExitUrl = await peekSubscriptionExplicitExitUrl(characterSlug);
if (pendingExitUrl) return pendingExitUrl; if (pendingExitUrl) return pendingExitUrl;
@@ -40,6 +40,7 @@ describe("payment search params", () => {
it("accepts only supported subscription return targets", () => { it("accepts only supported subscription return targets", () => {
expect(parseSubscriptionReturnTo("chat")).toBe("chat"); expect(parseSubscriptionReturnTo("chat")).toBe("chat");
expect(parseSubscriptionReturnTo("private-room")).toBe("private-room"); expect(parseSubscriptionReturnTo("private-room")).toBe("private-room");
expect(parseSubscriptionReturnTo("sidebar")).toBe("sidebar");
expect(parseSubscriptionReturnTo(["private-room", "chat"])).toBe( expect(parseSubscriptionReturnTo(["private-room", "chat"])).toBe(
"private-room", "private-room",
); );
@@ -54,6 +54,40 @@ describe("pending payment order helpers", () => {
await clearPendingPaymentOrder(); 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", () => { it("routes tip payments back to the tip page", () => {
expect( expect(
buildPendingPaymentSubscriptionUrl({ buildPendingPaymentSubscriptionUrl({
+7 -1
View File
@@ -26,7 +26,13 @@ export function parseSubscriptionReturnTo(
value: PaymentSearchParamValue, value: PaymentSearchParamValue,
): AppSubscriptionReturnTo { ): AppSubscriptionReturnTo {
const returnTo = getFirstPaymentSearchParam(value); const returnTo = getFirstPaymentSearchParam(value);
if (returnTo === "chat" || returnTo === "private-room") return returnTo; if (
returnTo === "chat" ||
returnTo === "private-room" ||
returnTo === "sidebar"
) {
return returnTo;
}
return null; return null;
} }
@@ -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",
);
});
});
@@ -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,
},
]);
});
});
@@ -59,9 +59,10 @@ describe("navigation resolver", () => {
ROUTE_BUILDERS.subscription("topup", { ROUTE_BUILDERS.subscription("topup", {
payChannel: "stripe", payChannel: "stripe",
returnTo: "private-room", returnTo: "private-room",
sourceCharacterSlug: "maya",
}), }),
).toBe( ).toBe(
"/subscription?type=topup&payChannel=stripe&returnTo=private-room", "/subscription?type=topup&payChannel=stripe&returnTo=private-room&character=maya",
); );
}); });
+13 -4
View File
@@ -4,7 +4,7 @@ import { getRouteAccess, isInternalRoute } from "../route-meta";
import { ALL_STATIC_ROUTES, getCharacterRoutes, ROUTES } from "../routes"; import { ALL_STATIC_ROUTES, getCharacterRoutes, ROUTES } from "../routes";
describe("route meta", () => { 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.splash)).toBe("authOnly");
expect(getRouteAccess(ROUTES.auth)).toBe("authOnly"); expect(getRouteAccess(ROUTES.auth)).toBe("authOnly");
expect(getRouteAccess(ROUTES.chat)).toBe("guestEntry"); expect(getRouteAccess(ROUTES.chat)).toBe("guestEntry");
@@ -23,13 +23,22 @@ describe("route meta", () => {
it("classifies character-scoped routes by their page", () => { it("classifies character-scoped routes by their page", () => {
const routes = getCharacterRoutes("maya"); 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.splash)).toBe("authOnly");
expect(getRouteAccess(routes.chat)).toBe("guestEntry"); expect(getRouteAccess(routes.chat)).toBe("guestEntry");
expect(getRouteAccess(routes.privateRoom)).toBe("guestEntry"); expect(getRouteAccess(routes.privateRoom)).toBe("guestEntry");
expect(getRouteAccess(routes.tip)).toBe("public"); 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", () => { it("includes private room in static routes", () => {
+76
View File
@@ -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;
}
@@ -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,
},
]);
+5 -1
View File
@@ -7,7 +7,11 @@ import type {
} from "@/data/storage/navigation"; } from "@/data/storage/navigation";
export type AppSubscriptionType = "vip" | "topup"; export type AppSubscriptionType = "vip" | "topup";
export type AppSubscriptionReturnTo = "chat" | "private-room" | null; export type AppSubscriptionReturnTo =
| "chat"
| "private-room"
| "sidebar"
| null;
export interface OpenSubscriptionInput { export interface OpenSubscriptionInput {
type: AppSubscriptionType; type: AppSubscriptionType;
+3 -6
View File
@@ -7,7 +7,7 @@ export type RouteAccess =
| "realUser" | "realUser"
| "authOnly"; | "authOnly";
const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = { const GLOBAL_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.root]: "public", [ROUTES.root]: "public",
[ROUTES.splash]: "authOnly", [ROUTES.splash]: "authOnly",
[ROUTES.auth]: "authOnly", [ROUTES.auth]: "authOnly",
@@ -26,14 +26,11 @@ const CHARACTER_ROUTE_ACCESS: Readonly<Record<string, RouteAccess>> = {
chat: "guestEntry", chat: "guestEntry",
"private-room": "guestEntry", "private-room": "guestEntry",
tip: "public", tip: "public",
sidebar: "session",
feedback: "session",
"coins-rules": "public",
}; };
export function getRouteAccess(pathname: string): RouteAccess { export function getRouteAccess(pathname: string): RouteAccess {
const staticAccess = STATIC_ROUTE_ACCESS[pathname]; const globalAccess = GLOBAL_ROUTE_ACCESS[pathname];
if (staticAccess) return staticAccess; if (globalAccess) return globalAccess;
const match = pathname.match(/^\/characters\/[^/]+\/([^/]+)\/?$/); const match = pathname.match(/^\/characters\/[^/]+\/([^/]+)\/?$/);
return (match?.[1] && CHARACTER_ROUTE_ACCESS[match[1]]) || "public"; return (match?.[1] && CHARACTER_ROUTE_ACCESS[match[1]]) || "public";
+3 -9
View File
@@ -38,9 +38,6 @@ export interface CharacterRoutes {
readonly chat: string; readonly chat: string;
readonly privateRoom: string; readonly privateRoom: string;
readonly tip: string; readonly tip: string;
readonly sidebar: string;
readonly feedback: string;
readonly coinsRules: string;
} }
export function getCharacterRoutes( export function getCharacterRoutes(
@@ -52,9 +49,6 @@ export function getCharacterRoutes(
chat: `${root}/chat`, chat: `${root}/chat`,
privateRoom: `${root}/private-room`, privateRoom: `${root}/private-room`,
tip: `${root}/tip`, tip: `${root}/tip`,
sidebar: `${root}/sidebar`,
feedback: `${root}/feedback`,
coinsRules: `${root}/coins-rules`,
}; };
} }
@@ -86,15 +80,15 @@ export const ROUTE_BUILDERS = {
options: { options: {
payChannel?: PayChannel; payChannel?: PayChannel;
returnTo?: Exclude<AppSubscriptionReturnTo, null>; returnTo?: Exclude<AppSubscriptionReturnTo, null>;
characterSlug?: string; sourceCharacterSlug?: string;
analytics?: PaymentAnalyticsContext; analytics?: PaymentAnalyticsContext;
} = {}, } = {},
): `/subscription?${string}` => { ): `/subscription?${string}` => {
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
if (options.payChannel) params.set("payChannel", options.payChannel); if (options.payChannel) params.set("payChannel", options.payChannel);
if (options.returnTo) params.set("returnTo", options.returnTo); if (options.returnTo) params.set("returnTo", options.returnTo);
if (options.characterSlug) { if (options.sourceCharacterSlug) {
params.set("character", options.characterSlug); params.set("character", options.sourceCharacterSlug);
} }
if (options.analytics) { if (options.analytics) {
params.set( params.set(
+25 -117
View File
@@ -1,39 +1,20 @@
"use client"; "use client";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { NavigationStorage } from "@/data/storage/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 { import {
useActiveCharacter, useActiveCharacter,
useActiveCharacterRoutes, useActiveCharacterRoutes,
} from "@/providers/character-provider"; } from "@/providers/character-provider";
import { import { type Route } from "./routes";
ROUTE_BUILDERS,
type Route,
} from "./routes";
import {
isAuthenticatedUser as resolveIsAuthenticatedUser,
resolveAuthenticatedNavigation,
} from "./navigation-resolver";
import type { import type {
OpenSubscriptionForPendingUnlockInput, OpenSubscriptionForPendingUnlockInput,
OpenSubscriptionInput, OpenSubscriptionInput,
StartMessageUnlockInput, StartMessageUnlockInput,
} from "./navigation-types"; } from "./navigation-types";
import { useGlobalAppNavigator } from "./use-global-app-navigator";
export interface AppNavigator { export interface AppNavigator {
push: (href: string, options?: { scroll?: boolean }) => void; push: (href: string, options?: { scroll?: boolean }) => void;
@@ -42,96 +23,55 @@ export interface AppNavigator {
openAuth: (redirectTo?: string) => void; openAuth: (redirectTo?: string) => void;
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void; openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
openSubscription: (input: OpenSubscriptionInput) => void; openSubscription: (input: OpenSubscriptionInput) => void;
exitSubscription: (
returnTo: SubscriptionReturnTo,
characterSlug?: string,
) => void;
exitSubscriptionAfterSuccess: (
returnTo: SubscriptionReturnTo,
characterSlug?: string,
) => void;
startMessageUnlock: (input: StartMessageUnlockInput) => void; startMessageUnlock: (input: StartMessageUnlockInput) => void;
openSubscriptionForPendingUnlock: ( openSubscriptionForPendingUnlock: (
input: OpenSubscriptionForPendingUnlockInput, input: OpenSubscriptionForPendingUnlockInput,
) => void; ) => void;
getDefaultPayChannel: () => ReturnType<typeof getDefaultPayChannelForCountryCode>; getDefaultPayChannel: ReturnType<
typeof useGlobalAppNavigator
>["getDefaultPayChannel"];
isAuthenticatedUser: boolean; isAuthenticatedUser: boolean;
} }
export function useAppNavigator(): AppNavigator { export function useAppNavigator(): AppNavigator {
const router = useRouter(); const globalNavigator = useGlobalAppNavigator();
const character = useActiveCharacter(); const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const loginStatus = useAuthSelector((state) => state.context.loginStatus); const {
const countryCode = useUserSelector( back,
(state) => state.context.currentUser?.countryCode, getDefaultPayChannel,
); isAuthenticatedUser,
const isAuthenticatedUser = resolveIsAuthenticatedUser(loginStatus); push,
replace,
const push = useCallback((href: string, options?: { scroll?: boolean }): void => { } = globalNavigator;
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( const openAuth = useCallback(
(redirectTo: string = characterRoutes.chat): void => { (redirectTo: string = characterRoutes.chat): void => {
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo)); globalNavigator.openAuth(redirectTo);
}, },
[characterRoutes.chat, router], [characterRoutes.chat, globalNavigator],
); );
const openChat = useCallback( const openChat = useCallback(
(options: { replace?: boolean; scroll?: boolean } = {}): void => { (options: { replace?: boolean; scroll?: boolean } = {}): void => {
const navOptions = { scroll: options.scroll ?? false }; const navOptions = { scroll: options.scroll ?? false };
if (options.replace) { if (options.replace) {
router.replace(characterRoutes.chat, navOptions); replace(characterRoutes.chat, navOptions);
return; 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( const openSubscription = useCallback(
({ (input: OpenSubscriptionInput): void => {
type, globalNavigator.openSubscription({
payChannel = getDefaultPayChannel(), ...input,
returnTo = null, sourceCharacterSlug: character.slug,
replace: shouldReplace = false,
analytics = getDefaultPaymentAnalyticsContext(type),
}: OpenSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
returnTo: returnTo ?? undefined,
characterSlug: character.slug,
analytics,
}); });
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( const startMessageUnlock = useCallback(
@@ -163,10 +103,10 @@ export function useAppNavigator(): AppNavigator {
returnUrl, returnUrl,
stage, stage,
}); });
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl)); globalNavigator.openAuth(returnUrl);
})(); })();
}, },
[character.id, isAuthenticatedUser, router], [character.id, globalNavigator, isAuthenticatedUser],
); );
const openSubscriptionForPendingUnlock = useCallback( const openSubscriptionForPendingUnlock = useCallback(
@@ -205,34 +145,6 @@ export function useAppNavigator(): AppNavigator {
[character.id, getDefaultPayChannel, openSubscription], [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(() => ({ return useMemo(() => ({
push, push,
replace, replace,
@@ -240,16 +152,12 @@ export function useAppNavigator(): AppNavigator {
openAuth, openAuth,
openChat, openChat,
openSubscription, openSubscription,
exitSubscription,
exitSubscriptionAfterSuccess,
startMessageUnlock, startMessageUnlock,
openSubscriptionForPendingUnlock, openSubscriptionForPendingUnlock,
getDefaultPayChannel, getDefaultPayChannel,
isAuthenticatedUser, isAuthenticatedUser,
}), [ }), [
back, back,
exitSubscription,
exitSubscriptionAfterSuccess,
getDefaultPayChannel, getDefaultPayChannel,
isAuthenticatedUser, isAuthenticatedUser,
openAuth, openAuth,
+126
View File
@@ -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,
],
);
}
@@ -1,84 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Result } from "@/utils/result";
const mocks = vi.hoisted(() => ({
getDeviceId: vi.fn(),
getLocalMessages: vi.fn(),
syncGuestHistory: vi.fn(),
clearLocalMessages: vi.fn(),
getGuestChatOwnerKey: vi.fn(),
}));
vi.mock("@/data/storage/auth", () => ({
AuthStorage: {
getInstance: () => ({ getDeviceId: mocks.getDeviceId }),
},
}));
vi.mock("@/data/repositories/chat_repository_loader", () => ({
loadChatRepository: async () => ({
getLocalMessages: mocks.getLocalMessages,
syncGuestHistory: mocks.syncGuestHistory,
clearLocalMessages: mocks.clearLocalMessages,
}),
}));
vi.mock("@/data/storage/navigation", () => ({
NavigationStorage: {
getGuestChatOwnerKey: mocks.getGuestChatOwnerKey,
},
}));
import { syncGuestHistoriesToUser } from "../guest-history-sync";
describe("syncGuestHistoriesToUser", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getDeviceId.mockResolvedValue(Result.ok("device-1"));
mocks.getGuestChatOwnerKey.mockResolvedValue(null);
mocks.clearLocalMessages.mockResolvedValue(Result.ok(undefined));
});
it("groups guest history by character and clears only successful caches", async () => {
mocks.getLocalMessages.mockImplementation(async (identity: string) =>
Result.ok([
{
id: `${identity}:message`,
role: "user",
type: "text",
content: identity,
createdAt: "2026-07-20T00:00:00.000Z",
},
]),
);
mocks.syncGuestHistory.mockImplementation(
async (request: { characterId: string }) =>
request.characterId === "elio"
? Result.ok(undefined)
: Result.err(new Error("offline")),
);
await syncGuestHistoriesToUser(["elio", "maya-tan"]);
expect(mocks.syncGuestHistory).toHaveBeenCalledTimes(2);
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
expect.objectContaining({ characterId: "elio" }),
);
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
expect.objectContaining({ characterId: "maya-tan" }),
);
expect(mocks.clearLocalMessages).toHaveBeenCalledOnce();
expect(mocks.clearLocalMessages).toHaveBeenCalledWith(
"device:device-1::character:elio",
);
});
it("skips characters without guest messages", async () => {
mocks.getLocalMessages.mockResolvedValue(Result.ok([]));
await syncGuestHistoriesToUser(["elio"]);
expect(mocks.syncGuestHistory).not.toHaveBeenCalled();
});
});
-65
View File
@@ -1,65 +0,0 @@
import { AuthStorage } from "@/data/storage/auth";
import { NavigationStorage } from "@/data/storage/navigation";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { ChatSyncRequestSchema } from "@/data/schemas/chat";
import { buildChatConversationKey } from "@/lib/chat/chat_cache_keys";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
const log = new Logger("StoresChatGuestHistorySync");
export async function syncGuestHistoriesToUser(
characterIds: readonly string[],
): Promise<void> {
const deviceResult = await AuthStorage.getInstance().getDeviceId();
const savedOwnerKey = await NavigationStorage.getGuestChatOwnerKey();
const ownerKey =
savedOwnerKey ??
(Result.isOk(deviceResult) && deviceResult.data
? `device:${deviceResult.data}`
: null);
if (!ownerKey) return;
const repository = await loadChatRepository();
await Promise.allSettled(
[...new Set(characterIds)].map(async (characterId) => {
const cacheIdentity = buildChatConversationKey(ownerKey, characterId);
const messagesResult = await repository.getLocalMessages(cacheIdentity);
if (Result.isErr(messagesResult) || messagesResult.data.length === 0) return;
const messages = messagesResult.data.flatMap((message) => {
if (message.role !== "user" && message.role !== "assistant") return [];
return [{
role: message.role,
content: message.content,
timestamp: normalizeTimestamp(message.createdAt),
}];
});
if (messages.length === 0) return;
const request = ChatSyncRequestSchema.parse({ characterId, messages });
const syncResult = await repository.syncGuestHistory(request);
if (Result.isErr(syncResult)) {
log.warn("[chat-sync] guest history sync failed", {
characterId,
error: syncResult.error,
});
return;
}
const clearResult = await repository.clearLocalMessages(cacheIdentity);
if (Result.isErr(clearResult)) {
log.warn("[chat-sync] guest history cleanup failed", {
characterId,
error: clearResult.error,
});
}
}),
);
}
function normalizeTimestamp(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? new Date().toISOString()
: parsed.toISOString();
}
+2 -22
View File
@@ -16,11 +16,6 @@ import {
useAuthSelector, useAuthSelector,
} from "@/stores/auth/auth-context"; } from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { syncGuestHistoriesToUser } from "@/stores/chat/guest-history-sync";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { NavigationStorage } from "@/data/storage/navigation";
import { Result } from "@/utils/result";
export function ChatAuthSync() { export function ChatAuthSync() {
const authState = useAuthSelector( const authState = useAuthSelector(
@@ -32,7 +27,6 @@ export function ChatAuthSync() {
shallowEqual, shallowEqual,
); );
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const characterCatalog = useCharacterCatalog();
const prevSessionKeyRef = useRef<string | null>(null); const prevSessionKeyRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -48,27 +42,14 @@ export function ChatAuthSync() {
} }
if (authState.loginStatus === "guest") { if (authState.loginStatus === "guest") {
let cancelled = false; chatDispatch({ type: "ChatGuestLogin" });
void (async () => { return;
const ownerResult = await resolveChatCacheOwnerKey();
if (Result.isOk(ownerResult)) {
await NavigationStorage.saveGuestChatOwnerKey(ownerResult.data);
}
if (!cancelled) chatDispatch({ type: "ChatGuestLogin" });
})();
return () => {
cancelled = true;
};
} }
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
const tokenR = await AuthStorage.getInstance().getLoginToken(); const tokenR = await AuthStorage.getInstance().getLoginToken();
if (!cancelled && tokenR.success && tokenR.data) { if (!cancelled && tokenR.success && tokenR.data) {
await syncGuestHistoriesToUser(
characterCatalog.characters.map((character) => character.id),
);
if (cancelled) return;
chatDispatch({ chatDispatch({
type: "ChatUserLogin", type: "ChatUserLogin",
token: tokenR.data, token: tokenR.data,
@@ -83,7 +64,6 @@ export function ChatAuthSync() {
authState.hasInitialized, authState.hasInitialized,
authState.isLoading, authState.isLoading,
authState.loginStatus, authState.loginStatus,
characterCatalog.characters,
chatDispatch, chatDispatch,
]); ]);