Compare commits
3 Commits
b216b53f2e
...
bfe48439b3
| Author | SHA1 | Date | |
|---|---|---|---|
| bfe48439b3 | |||
| 4d1c85727a | |||
| 1f7ab2be04 |
@@ -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。
|
|
||||||
|
|||||||
@@ -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 />;
|
|
||||||
}
|
|
||||||
@@ -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"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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" }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user