Compare commits

..

2 Commits

Author SHA1 Message Date
Codex cc9fb9f4fd Add chat gifts and relationship diary UI
Docker Image / Quality and Bundle Budgets (push) Successful in 3s
Docker Image / Build and Push Docker Image (push) Successful in 1m55s
2026-07-21 17:41:30 +08:00
admin dd7d7d5dcf chore(favicon): 使用预发布环境的图标
Docker Image / Quality and Bundle Budgets (push) Successful in 3s
Docker Image / Build and Push Docker Image (push) Successful in 1m58s
2026-07-21 17:30:59 +08:00
118 changed files with 2206 additions and 634 deletions
+1 -1
View File
@@ -224,7 +224,7 @@ payChannel = ezpay
subscriptionType = vip | topup | tip subscriptionType = vip | topup | tip
giftCategory(仅 Tip,可空) giftCategory(仅 Tip,可空)
giftPlanId(仅 Tip,可空) giftPlanId(仅 Tip,可空)
returnTo = chat | private-room | profile(可选) returnTo = chat | private-room | sidebar(可选)
characterSlug(可选) characterSlug(可选)
createdAt createdAt
``` ```
+1 -1
View File
@@ -11,7 +11,7 @@ export const splashStartChatButtonName = "Start Chatting";
export const defaultCharacterSlug = "elio"; export const defaultCharacterSlug = "elio";
export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`; export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`;
export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`; export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`;
export const defaultCharacterProfilePath = `/profile?returnTo=%2Fcharacters%2F${defaultCharacterSlug}%2Fchat`; export const defaultCharacterSidebarPath = `/sidebar?returnTo=%2Fcharacters%2F${defaultCharacterSlug}%2Fchat`;
export const defaultCharacterChatUrl = new RegExp( export const defaultCharacterChatUrl = new RegExp(
`/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`, `/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`,
); );
+1 -1
View File
@@ -27,7 +27,7 @@ export async function enterChatFromSplash(page: Page, options: { expectedUrl?: R
await expect(startChatButton).toBeVisible({ timeout }); await expect(startChatButton).toBeVisible({ timeout });
await expect(startChatButton).toBeEnabled(); await expect(startChatButton).toBeEnabled();
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
await startChatButton.click({ timeout: 3_000 }).catch(() => {}); await startChatButton.click();
try { await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); return; } catch { await page.waitForTimeout(500); } try { await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); return; } catch { await page.waitForTimeout(500); }
} }
await expect(page).toHaveURL(expectedUrl, { timeout }); await expect(page).toHaveURL(expectedUrl, { timeout });
@@ -31,5 +31,5 @@ test("user can email login from other sign-in options and return to chat", async
expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform); expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform);
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
await expect(page.getByRole("button", { name: "Profile" })).toBeVisible(); await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
}); });
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { import {
clearBrowserState, clearBrowserState,
defaultCharacterProfilePath, defaultCharacterSidebarPath,
defaultCharacterSplashPath, defaultCharacterSplashPath,
defaultCharacterChatUrl, defaultCharacterChatUrl,
enterChatFromSplash, enterChatFromSplash,
@@ -17,7 +17,7 @@ test.beforeEach(async ({ baseURL, context, page }) => {
await mockCoreApis(page); await mockCoreApis(page);
}); });
test("user can log out from the profile after email login", async ({ test("user can log out from the sidebar after email login", async ({
page, page,
}) => { }) => {
await enterChatFromSplash(page); await enterChatFromSplash(page);
@@ -27,11 +27,11 @@ test("user can log out from the profile after email login", async ({
await expect(page).toHaveURL(/\/auth(?:\?.*)?$/); await expect(page).toHaveURL(/\/auth(?:\?.*)?$/);
await switchToEmailSignIn(page); await switchToEmailSignIn(page);
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl }); await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
await expect(page.getByRole("button", { name: "Profile" })).toBeVisible(); await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
await page.getByRole("button", { name: "Profile" }).click(); await page.getByRole("button", { name: "Menu" }).click();
await expect(page).toHaveURL( await expect(page).toHaveURL(
new RegExp(defaultCharacterProfilePath.replace("?", "\\?") + "$"), new RegExp(defaultCharacterSidebarPath.replace("?", "\\?") + "$"),
); );
const logoutRequestPromise = page.waitForRequest("**/api/auth/logout"); const logoutRequestPromise = page.waitForRequest("**/api/auth/logout");
@@ -1,86 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import {
clearBrowserState,
defaultCharacterProfilePath,
dismissChatInterruptions,
enterChatFromSplash,
submitEmailLogin,
switchToEmailSignIn,
} from "@e2e/fixtures/test-helpers";
const characters = [
{ slug: "elio", displayName: "Elio Silvestri" },
{ slug: "maya", displayName: "Maya Tan" },
{ slug: "nayeli", displayName: "Nayeli Cervantes" },
] as const;
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("guest user avatar returns to Profile after sign-in", async ({
page,
}) => {
await enterChatFromSplash(page, { timeout: 30_000 });
await sendMessage(page, "Open my profile");
await page.getByRole("button", { name: "Open profile" }).click();
await expect(page).toHaveURL(/\/auth\?redirect=/);
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
defaultCharacterProfilePath,
);
await switchToEmailSignIn(page);
await submitEmailLogin(page, {
expectedUrl: new RegExp(
`${defaultCharacterProfilePath.replace("?", "\\?")}$`,
),
});
});
for (const character of characters) {
test(`${character.displayName} avatar opens the matching Private Room`, async ({
page,
}) => {
await enterCharacterChat(page, character.slug);
await sendMessage(page, `Hello ${character.displayName}`);
await page
.getByRole("button", {
name: `Open ${character.displayName}'s private room`,
})
.last()
.click();
await expect(page).toHaveURL(
new RegExp(`/characters/${character.slug}/private-room(?:\\?.*)?$`),
);
});
}
async function enterCharacterChat(page: Page, characterSlug: string) {
const chatPath = `/characters/${characterSlug}/chat`;
await page.goto(`/characters/${characterSlug}/splash`);
await page.getByRole("button", { name: "Start Chatting" }).click();
await expect(page).toHaveURL(new RegExp(`${chatPath}(?:\\?.*)?$`));
await dismissChatInterruptions(page);
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({
timeout: 20_000,
});
}
async function sendMessage(page: Page, message: string) {
await dismissChatInterruptions(page);
const messageInput = page.getByRole("textbox", { name: "Message" });
await expect(messageInput).toBeEnabled({ timeout: 20_000 });
await messageInput.fill(message);
await messageInput.press("Enter");
await expect(page.getByRole("button", { name: "Open profile" })).toBeVisible({
timeout: 10_000,
});
}
@@ -42,7 +42,7 @@ test.describe("pre-release email login smoke", () => {
urlTimeout: 20_000, urlTimeout: 20_000,
}); });
await expect(page.getByRole("button", { name: "Profile" })).toBeVisible(); await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
}); });
}); });
+2 -2
View File
@@ -23,12 +23,12 @@ const nextConfig: NextConfig = {
{ {
protocol: "https", protocol: "https",
hostname: "**.supabase.co", hostname: "**.supabase.co",
pathname: "/storage/v1/object/public/**", pathname: "/storage/v1/object/**",
}, },
{ {
protocol: "https", protocol: "https",
hostname: "dbapi.banlv-ai.com", hostname: "dbapi.banlv-ai.com",
pathname: "/storage/v1/object/public/**", pathname: "/storage/v1/object/**",
}, },
{ {
protocol: "https", protocol: "https",
+1 -1
View File
@@ -25,7 +25,7 @@
"test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium", "test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium",
"test:e2e:headed": "playwright test --project=mock-chromium --headed", "test:e2e:headed": "playwright test --project=mock-chromium --headed",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-profile.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts", "test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-sidebar.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts",
"test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend", "test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend",
"test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=prod-smoke", "test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=prod-smoke",
"contract:check": "node scripts/contracts/check-openapi.mjs", "contract:check": "node scripts/contracts/check-openapi.mjs",

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -79,34 +79,4 @@ describe("shared Tailwind components", () => {
expect(guestHtml).toContain('aria-label="Guest avatar"'); expect(guestHtml).toContain('aria-label="Guest avatar"');
expect(guestHtml).toContain("%2Fimages%2Favatar%2Fplaceholder.png"); expect(guestHtml).toContain("%2Fimages%2Favatar%2Fplaceholder.png");
}); });
it("renders interactive avatars as accessible buttons", () => {
const characterHtml = renderToStaticMarkup(
<CharacterAvatar
src="/images/avatar/elio.png"
alt="Elio Silvestri"
actionLabel="Open Elio's private room"
analyticsKey="chat.open_private_room_from_avatar"
onClick={() => undefined}
/>,
);
const userHtml = renderToStaticMarkup(
<UserMessageAvatar
actionLabel="Open profile"
analyticsKey="chat.open_profile_from_avatar"
onClick={() => undefined}
/>,
);
expect(characterHtml).toContain('<button type="button"');
expect(characterHtml).toContain(
'aria-label="Open Elio&#x27;s private room"',
);
expect(characterHtml).toContain(
'data-analytics-key="chat.open_private_room_from_avatar"',
);
expect(userHtml).toContain('<button type="button"');
expect(userHtml).toContain('aria-label="Open profile"');
expect(userHtml).toContain("focus-visible:outline-3");
});
}); });
-11
View File
@@ -1,11 +0,0 @@
export type AvatarInteractionProps =
| {
onClick?: undefined;
actionLabel?: never;
analyticsKey?: never;
}
| {
onClick: () => void;
actionLabel: string;
analyticsKey?: string;
};
+8 -37
View File
@@ -3,9 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { AvatarInteractionProps } from "./avatar-interaction"; export interface CharacterAvatarProps {
interface CharacterAvatarVisualProps {
src: string; src: string;
alt: string; alt: string;
className?: string; className?: string;
@@ -14,9 +12,6 @@ interface CharacterAvatarVisualProps {
priority?: boolean; priority?: boolean;
} }
export type CharacterAvatarProps = CharacterAvatarVisualProps &
AvatarInteractionProps;
export function CharacterAvatar({ export function CharacterAvatar({
src, src,
alt, alt,
@@ -24,9 +19,6 @@ export function CharacterAvatar({
size = 43, size = 43,
imageSize, imageSize,
priority = false, priority = false,
onClick,
actionLabel,
analyticsKey,
}: CharacterAvatarProps) { }: CharacterAvatarProps) {
const resolvedImageSize = const resolvedImageSize =
imageSize ?? (typeof size === "number" ? size : 96); imageSize ?? (typeof size === "number" ? size : 96);
@@ -35,16 +27,16 @@ export function CharacterAvatar({
height: size, height: size,
}; };
const avatarClassName = [ return (
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border-0 bg-(--color-avatar-border,#fbf3f5)", <span
onClick className={[
? "cursor-pointer p-0 transition-transform duration-150 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-accent active:scale-96" "inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--color-avatar-border,#fbf3f5)",
: undefined,
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ")}
const image = ( style={style}
>
<Image <Image
src={src} src={src}
alt={alt} alt={alt}
@@ -53,27 +45,6 @@ export function CharacterAvatar({
priority={priority} priority={priority}
className="size-full object-cover" className="size-full object-cover"
/> />
);
if (onClick) {
return (
<button
type="button"
className={avatarClassName}
style={style}
aria-label={actionLabel}
data-analytics-key={analyticsKey}
data-analytics-label={actionLabel}
onClick={onClick}
>
{image}
</button>
);
}
return (
<span className={avatarClassName} style={style}>
{image}
</span> </span>
); );
} }
-1
View File
@@ -3,6 +3,5 @@
*/ */
export * from "./back-button"; export * from "./back-button";
export * from "./avatar-interaction";
export * from "./character-avatar"; export * from "./character-avatar";
export * from "./user-message-avatar"; export * from "./user-message-avatar";
+19 -33
View File
@@ -2,30 +2,19 @@
import Image from "next/image"; import Image from "next/image";
import type { AvatarInteractionProps } from "./avatar-interaction"; export interface UserMessageAvatarProps {
interface UserMessageAvatarVisualProps {
avatarUrl?: string | null; avatarUrl?: string | null;
className?: string; className?: string;
size?: number | string; size?: number | string;
} }
export type UserMessageAvatarProps = UserMessageAvatarVisualProps &
AvatarInteractionProps;
export function UserMessageAvatar({ export function UserMessageAvatar({
avatarUrl, avatarUrl,
className, className,
size = 43, size = 43,
onClick,
actionLabel,
analyticsKey,
}: UserMessageAvatarProps) { }: UserMessageAvatarProps) {
const avatarClassName = [ const avatarClassName = [
"flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))", "flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))",
onClick
? "cursor-pointer p-0 transition-transform duration-150 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-accent active:scale-96"
: undefined,
className, className,
] ]
.filter(Boolean) .filter(Boolean)
@@ -33,30 +22,21 @@ export function UserMessageAvatar({
const avatarStyle = { width: size, height: size }; const avatarStyle = { width: size, height: size };
const imageSize = typeof size === "number" ? size : 64; const imageSize = typeof size === "number" ? size : 64;
const hasUserAvatar = Boolean(avatarUrl && avatarUrl.length > 0); if (avatarUrl && avatarUrl.length > 0) {
const image = ( return (
<div
className={avatarClassName}
style={avatarStyle}
aria-label="User avatar"
>
<Image <Image
src={hasUserAvatar ? avatarUrl! : "/images/avatar/placeholder.png"} src={avatarUrl}
alt={hasUserAvatar ? "" : "Guest"} alt=""
width={imageSize} width={imageSize}
height={imageSize} height={imageSize}
className="size-full object-cover" className="size-full object-cover"
/> />
); </div>
if (onClick) {
return (
<button
type="button"
className={avatarClassName}
style={avatarStyle}
aria-label={actionLabel}
data-analytics-key={analyticsKey}
data-analytics-label={actionLabel}
onClick={onClick}
>
{image}
</button>
); );
} }
@@ -64,9 +44,15 @@ export function UserMessageAvatar({
<div <div
className={avatarClassName} className={avatarClassName}
style={avatarStyle} style={avatarStyle}
aria-label={hasUserAvatar ? "User avatar" : "Guest avatar"} aria-label="Guest avatar"
> >
{image} <Image
src="/images/avatar/placeholder.png"
alt="Guest"
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
</div> </div>
); );
} }
+1 -22
View File
@@ -15,9 +15,7 @@ import {
} from "@/providers/character-provider"; } from "@/providers/character-provider";
import { useCharacterCatalog } from "@/providers/character-catalog-provider"; import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session"; import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
import { buildGlobalPageUrl } from "@/router/global-route-context"; import { getCharacterRoutes } from "@/router/routes";
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
@@ -59,10 +57,6 @@ export function ChatScreen() {
const refreshCharacterCatalog = characterCatalog.refresh; const refreshCharacterCatalog = characterCatalog.refresh;
const defaultCharacterSlug = characterCatalog.defaultCharacter.slug; const defaultCharacterSlug = characterCatalog.defaultCharacter.slug;
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const profileUrl = buildGlobalPageUrl(
ROUTES.profile,
characterRoutes.chat,
);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const state = useChatState(); const state = useChatState();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
@@ -240,19 +234,6 @@ export function ChatScreen() {
}); });
} }
function handleOpenUserProfile(): void {
router.push(
resolveAuthenticatedNavigation({
loginStatus: authState.loginStatus,
targetUrl: profileUrl,
}),
);
}
function handleOpenCharacterPrivateRoom(): void {
router.push(characterRoutes.privateRoom);
}
return ( return (
<MobileShell> <MobileShell>
<div <div
@@ -299,8 +280,6 @@ export function ChatScreen() {
onUnlockVoiceMessage={handleUnlockVoiceMessage} onUnlockVoiceMessage={handleUnlockVoiceMessage}
onUnlockImageMessage={handleUnlockImageMessage} onUnlockImageMessage={handleUnlockImageMessage}
onOpenImage={handleOpenImage} onOpenImage={handleOpenImage}
onUserAvatarClick={handleOpenUserProfile}
onCharacterAvatarClick={handleOpenCharacterPrivateRoom}
onLoadMoreHistory={() => { onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}} }}
@@ -0,0 +1,174 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
}));
vi.mock("@/stores/chat/chat-context", () => ({
useChatDispatch: () => mocks.dispatch,
}));
vi.mock("@/providers/character-provider", () => ({
useActiveCharacter: () => ({ id: "maya-tan" }),
useActiveCharacterRoutes: () => ({
tip: "/characters/maya/tip",
}),
}));
vi.mock("../../hooks/use-chat-keyboard-avoidance", () => ({
useChatKeyboardAvoidance: () => undefined,
}));
vi.mock("../../hooks/use-chat-keyboard-diagnostics", () => ({
useChatKeyboardDiagnostics: () => undefined,
}));
import { ChatInputBar } from "../chat-input-bar";
describe("ChatInputBar", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.dispatch.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("opens the ordered action menu, blurs input, and links to Tip", () => {
renderBar();
const textarea = getTextarea();
act(() => textarea.focus());
expect(document.activeElement).toBe(textarea);
act(() => getButton("Open chat actions").click());
expect(document.activeElement).not.toBe(textarea);
const menu = container.querySelector('[aria-label="Chat actions"]');
expect(menu).not.toBeNull();
expect(
Array.from(menu?.children ?? []).map((item) => item.textContent?.trim()),
).toEqual(["Image", "Voice", "Tip"]);
expect(getButton("Close chat actions").getAttribute("aria-expanded")).toBe(
"true",
);
expect(
menu
?.querySelector<HTMLAnchorElement>('[data-analytics-key="chat.open_tip"]')
?.getAttribute("href"),
).toBe("/characters/maya/tip");
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
});
it("injects image and voice promotions without persisting UI state", () => {
renderBar();
act(() => getButton("Open chat actions").click());
act(() => getButton("Image").click());
const imageEvent = mocks.dispatch.mock.calls[0]?.[0];
expect(imageEvent).toMatchObject({
type: "ChatPromotionInjected",
promotion: {
characterId: "maya-tan",
promotionType: "image",
lockType: "image_paywall",
},
});
expect(imageEvent.promotion.clientLockId).toMatch(/^promotion_/);
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
act(() => getButton("Open chat actions").click());
act(() => getButton("Voice").click());
const voiceEvent = mocks.dispatch.mock.calls[1]?.[0];
expect(voiceEvent).toMatchObject({
type: "ChatPromotionInjected",
promotion: {
characterId: "maya-tan",
promotionType: "voice",
lockType: "voice_message",
},
});
expect(voiceEvent.promotion.clientLockId).not.toBe(
imageEvent.promotion.clientLockId,
);
});
it("switches from actions to Send for non-whitespace input", () => {
renderBar();
act(() => getButton("Open chat actions").click());
setTextareaValue(getTextarea(), "Hello Maya");
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
expect(getButton("Send message")).not.toBeNull();
act(() => getButton("Send message").click());
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "ChatSendMessage",
content: "Hello Maya",
});
expect(getTextarea().value).toBe("");
expect(document.activeElement).toBe(getTextarea());
setTextareaValue(getTextarea(), " ");
expect(getButton("Open chat actions")).not.toBeNull();
});
it("closes the menu on outside interaction and when disabled", () => {
renderBar();
act(() => getButton("Open chat actions").click());
act(() => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })));
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
act(() => getButton("Open chat actions").click());
renderBar(true);
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
expect(getButton("Open chat actions").disabled).toBe(true);
});
function renderBar(disabled = false): void {
act(() => root.render(<ChatInputBar disabled={disabled} />));
}
function getTextarea(): HTMLTextAreaElement {
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Missing chat textarea");
return textarea;
}
function getButton(label: string): HTMLButtonElement {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find(
(item) =>
item.getAttribute("aria-label") === label ||
item.textContent?.trim() === label,
);
if (!button) throw new Error(`Missing button: ${label}`);
return button;
}
});
function setTextareaValue(
textarea: HTMLTextAreaElement,
value: string,
): void {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
act(() => {
setter?.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
@@ -31,6 +31,7 @@ describe("chat Tailwind components", () => {
<MessageAvatar isFromAI={false} userAvatarUrl="/user-avatar.png" />, <MessageAvatar isFromAI={false} userAvatarUrl="/user-avatar.png" />,
); );
expect(aiHtml).toContain('aria-label="AI avatar"');
expect(aiHtml).toContain("size-(--chat-avatar-size,43px)"); expect(aiHtml).toContain("size-(--chat-avatar-size,43px)");
expect(aiHtml).toContain("size-full object-cover"); expect(aiHtml).toContain("size-full object-cover");
expect(aiHtml).toContain("%2Fimages%2Favatar%2Felio.png"); expect(aiHtml).toContain("%2Fimages%2Favatar%2Felio.png");
@@ -38,28 +39,6 @@ describe("chat Tailwind components", () => {
expect(userHtml).toContain("%2Fuser-avatar.png"); expect(userHtml).toContain("%2Fuser-avatar.png");
}); });
it("renders message avatar actions with explicit destinations", () => {
const aiHtml = renderWithCharacter(
<MessageAvatar isFromAI={true} onClick={() => undefined} />,
);
const userHtml = renderWithCharacter(
<MessageAvatar isFromAI={false} onClick={() => undefined} />,
);
expect(aiHtml).toContain('<button type="button"');
expect(aiHtml).toContain(
'data-analytics-key="chat.open_private_room_from_avatar"',
);
expect(aiHtml).toContain(
'aria-label="Open Elio Silvestri&#x27;s private room"',
);
expect(userHtml).toContain('<button type="button"');
expect(userHtml).toContain(
'data-analytics-key="chat.open_profile_from_avatar"',
);
expect(userHtml).toContain('aria-label="Open profile"');
});
it("renders the active character avatar", () => { it("renders the active character avatar", () => {
const maya = getCharacterBySlug("maya"); const maya = getCharacterBySlug("maya");
expect(maya).not.toBeNull(); expect(maya).not.toBeNull();
@@ -112,7 +91,10 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={false} disabled={false}
hasContent={true} hasContent={true}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -120,7 +102,21 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={false} disabled={false}
hasContent={false} hasContent={false}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined}
/>,
);
const openHtml = renderToStaticMarkup(
<ChatSendButton
disabled={false}
hasContent={false}
isMenuOpen={true}
menuId="chat-actions"
onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -128,7 +124,10 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={true} disabled={true}
hasContent={true} hasContent={true}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -136,12 +135,17 @@ describe("chat Tailwind components", () => {
expect(activeHtml).toContain('aria-label="Send message"'); expect(activeHtml).toContain('aria-label="Send message"');
expect(activeHtml).toContain('data-analytics-ignore="true"'); expect(activeHtml).toContain('data-analytics-ignore="true"');
expect(activeHtml).not.toContain("data-analytics-key"); expect(activeHtml).not.toContain("data-analytics-key");
expect(activeHtml).toContain("size-(--chat-send-button-size,40px)"); expect(activeHtml).toContain("size-(--chat-send-button-size,42px)");
expect(activeHtml).toContain( expect(activeHtml).toContain(
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
); );
expect(emptyHtml).toContain("bg-[#f8a8ce]"); expect(emptyHtml).toContain('aria-label="Open chat actions"');
expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]"); expect(emptyHtml).toContain('aria-expanded="false"');
expect(emptyHtml).toContain('aria-controls="chat-actions"');
expect(emptyHtml).toContain('data-analytics-key="chat.toggle_actions"');
expect(openHtml).toContain('aria-label="Close chat actions"');
expect(openHtml).toContain('aria-expanded="true"');
expect(openHtml).toContain("bg-[#38262d]");
expect(disabledHtml).toContain("disabled"); expect(disabledHtml).toContain("disabled");
}); });
@@ -217,12 +221,12 @@ describe("chat Tailwind components", () => {
expect(guestHtml).toContain('aria-label="Back to home"'); expect(guestHtml).toContain('aria-label="Back to home"');
expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"'); expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(guestHtml).toContain("px-(--chat-inline-padding,16px)"); expect(guestHtml).toContain("px-(--chat-inline-padding,16px)");
expect(guestHtml).not.toContain('aria-label="Profile"'); expect(guestHtml).not.toContain('aria-label="Menu"');
expect(memberHtml).toContain("Offer"); expect(memberHtml).toContain("Offer");
expect(memberHtml).toContain('href="/characters/elio/splash"'); expect(memberHtml).toContain('href="/characters/elio/splash"');
expect(memberHtml).toContain('aria-label="Back to home"'); expect(memberHtml).toContain('aria-label="Back to home"');
expect(memberHtml).toContain('aria-label="Profile"'); expect(memberHtml).toContain('aria-label="Menu"');
expect(memberHtml).toContain('data-analytics-key="chat.open_profile"'); expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"'); expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(memberHtml).toContain("px-(--chat-inline-padding,16px)"); expect(memberHtml).toContain("px-(--chat-inline-padding,16px)");
expect(memberHtml).not.toContain("px-(--spacing-md,12px)"); expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
@@ -243,7 +247,7 @@ describe("chat Tailwind components", () => {
memberWithHintHtml.indexOf( memberWithHintHtml.indexOf(
'data-analytics-key="chat.external_browser_hint"', 'data-analytics-key="chat.external_browser_hint"',
), ),
).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Profile"')); ).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Menu"'));
expect(guestWithHintHtml).not.toContain( expect(guestWithHintHtml).not.toContain(
'data-analytics-key="chat.external_browser_hint"', 'data-analytics-key="chat.external_browser_hint"',
); );
@@ -260,7 +264,7 @@ describe("chat Tailwind components", () => {
); );
expect(html).toContain('href="/characters/maya/splash"'); expect(html).toContain('href="/characters/maya/splash"');
expect(html).not.toContain('aria-label="Profile"'); expect(html).not.toContain('aria-label="Menu"');
expect(html).not.toContain( expect(html).not.toContain(
'data-analytics-key="chat.external_browser_hint"', 'data-analytics-key="chat.external_browser_hint"',
); );
+1 -10
View File
@@ -59,8 +59,6 @@ export interface ChatAreaProps {
onUnlockImageMessage?: ChatMessageAction; onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (displayMessageId: string) => void;
onLoadMoreHistory?: () => void; onLoadMoreHistory?: () => void;
onUserAvatarClick?: () => void;
onCharacterAvatarClick?: () => void;
} }
type ChatMessageAction = ( type ChatMessageAction = (
@@ -83,8 +81,6 @@ export function ChatArea({
onUnlockImageMessage, onUnlockImageMessage,
onOpenImage, onOpenImage,
onLoadMoreHistory, onLoadMoreHistory,
onUserAvatarClick,
onCharacterAvatarClick,
}: ChatAreaProps) { }: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
@@ -255,8 +251,6 @@ export function ChatArea({
onUnlockVoiceMessage, onUnlockVoiceMessage,
onUnlockImageMessage, onUnlockImageMessage,
onOpenImage, onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
)} )}
{isReplyingAI ? ( {isReplyingAI ? (
@@ -293,8 +287,6 @@ function renderMessagesWithDateHeaders(
onUnlockVoiceMessage?: ChatMessageAction, onUnlockVoiceMessage?: ChatMessageAction,
onUnlockImageMessage?: ChatMessageAction, onUnlockImageMessage?: ChatMessageAction,
onOpenImage?: (displayMessageId: string) => void, onOpenImage?: (displayMessageId: string) => void,
onUserAvatarClick?: () => void,
onCharacterAvatarClick?: () => void,
) { ) {
return buildChatRenderItems(messages, getMessageKey).map((item) => return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? ( item.type === "date" ? (
@@ -314,6 +306,7 @@ function renderMessagesWithDateHeaders(
lockReason={item.message.lockReason} lockReason={item.message.lockReason}
lockedPrivate={item.message.lockedPrivate} lockedPrivate={item.message.lockedPrivate}
privateMessageHint={item.message.privateMessageHint} privateMessageHint={item.message.privateMessageHint}
commercialAction={item.message.commercialAction}
isUnlockingMessage={ isUnlockingMessage={
isUnlockingMessage === true && isUnlockingMessage === true &&
item.message.displayId === unlockingMessageId item.message.displayId === unlockingMessageId
@@ -322,8 +315,6 @@ function renderMessagesWithDateHeaders(
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage} onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
onUserAvatarClick={onUserAvatarClick}
onCharacterAvatarClick={onCharacterAvatarClick}
/> />
), ),
); );
@@ -0,0 +1,148 @@
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 10px);
z-index: 4;
display: grid;
width: min(100%, 348px);
box-sizing: border-box;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 9px;
border: 1px solid rgba(77, 48, 57, 0.09);
border-radius: 24px;
background: rgba(255, 255, 255, 0.95);
box-shadow:
0 22px 54px rgba(74, 45, 55, 0.16),
0 4px 14px rgba(74, 45, 55, 0.08);
backdrop-filter: blur(20px);
transform-origin: right bottom;
}
.action {
display: flex;
min-width: 0;
min-height: 76px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 6px;
border: 0;
border-radius: 18px;
background: #faf7f8;
color: #543d45;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 850;
line-height: 1;
text-decoration: none;
transition:
background 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.action:disabled,
.action[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.48;
}
.icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 14px;
}
.imageIcon {
background: #fff0f4;
color: #d94f7c;
}
.voiceIcon {
background: #fff4e8;
color: #d87343;
}
.tipIcon {
background: #f7f0e8;
color: #986a42;
}
.action:focus-visible {
outline: 3px solid rgba(246, 87, 160, 0.28);
outline-offset: 2px;
}
@media (hover: hover) {
.action:not(:disabled):not([aria-disabled="true"]):hover {
background: #fff0f5;
color: #b83d69;
transform: translateY(-1px);
}
}
@media (max-width: 350px) {
.menu {
gap: 6px;
padding: 7px;
border-radius: 21px;
}
.action {
min-height: 70px;
border-radius: 16px;
font-size: 11px;
}
.icon {
width: 35px;
height: 35px;
}
}
@media (prefers-reduced-motion: no-preference) {
.menu {
animation: menuReveal 0.22s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.action {
animation: actionReveal 0.25s ease both;
}
.action:nth-child(2) {
animation-delay: 35ms;
}
.action:nth-child(3) {
animation-delay: 70ms;
}
}
@keyframes menuReveal {
from {
opacity: 0;
transform: translateY(7px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes actionReveal {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -0,0 +1,72 @@
"use client";
import { ImagePlus, Mic2, Coffee } from "lucide-react";
import Link from "next/link";
import styles from "./chat-composer-action-menu.module.css";
export interface ChatComposerActionMenuProps {
id: string;
disabled?: boolean;
tipHref: string;
onImage: () => void;
onVoice: () => void;
onNavigate: () => void;
}
export function ChatComposerActionMenu({
id,
disabled = false,
tipHref,
onImage,
onVoice,
onNavigate,
}: ChatComposerActionMenuProps) {
return (
<div id={id} className={styles.menu} aria-label="Chat actions">
<button
type="button"
className={styles.action}
disabled={disabled}
data-analytics-key="chat.promotion_image"
onClick={onImage}
>
<span className={`${styles.icon} ${styles.imageIcon}`}>
<ImagePlus size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Image</span>
</button>
<button
type="button"
className={styles.action}
disabled={disabled}
data-analytics-key="chat.promotion_voice"
onClick={onVoice}
>
<span className={`${styles.icon} ${styles.voiceIcon}`}>
<Mic2 size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Voice</span>
</button>
<Link
href={tipHref}
className={styles.action}
aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
data-analytics-key="chat.open_tip"
onClick={(event) => {
if (disabled) {
event.preventDefault();
return;
}
onNavigate();
}}
>
<span className={`${styles.icon} ${styles.tipIcon}`}>
<Coffee size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Tip</span>
</Link>
</div>
);
}
+7 -7
View File
@@ -2,11 +2,11 @@
/** /**
* ChatHeader 顶部栏 * ChatHeader 顶部栏
* *
* 图标:lucide-react <Lock />(游客 banner+ <UserRound />Profile * 图标:lucide-react <Lock />(游客 banner+ <Menu />(菜单按钮
* tree-shakablecurrentColor 继承父 color * tree-shakablecurrentColor 继承父 color
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Lock, UserRound } from "lucide-react"; 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";
@@ -74,17 +74,17 @@ export function ChatHeader({
<button <button
type="button" type="button"
data-analytics-key="chat.open_profile" data-analytics-key="chat.open_menu"
data-analytics-label="Open profile" 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={() => onClick={() =>
navigator.push( navigator.push(
buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat), buildGlobalPageUrl(ROUTES.sidebar, characterRoutes.chat),
) )
} }
aria-label="Profile" aria-label="Menu"
> >
<UserRound size={24} aria-hidden="true" /> <Menu size={24} aria-hidden="true" />
</button> </button>
</> </>
) : null} ) : null}
@@ -1,10 +1,10 @@
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
/* Keyboard inset is a total bottom inset, not an addition to normal spacing. */ /* Keyboard inset is a total bottom inset, not an addition to normal spacing. */
.bar { .bar {
position: relative;
z-index: 5;
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 padding: clamp(7px, 1.852vw, 10px)
calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px)) calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px))
max( max(
calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)), calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)),
@@ -12,28 +12,43 @@
) )
calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px)); calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px));
background: transparent; background: transparent;
transition: padding-top 0.2s ease, padding-bottom 0.2s ease, transition: padding-bottom 0.2s ease;
background-color 0.2s ease, box-shadow 0.2s ease;
} }
.barFocused { .composer {
padding-top: var(--spacing-md, 12px); position: relative;
background: #feeff2; width: 100%;
box-shadow: 0 4px 12px var(--color-input-box-shadow, rgba(0, 0, 0, 0.1));
} }
/* 内层:白底 + 大圆角(Dart AppRadius.radius32+ focused 时 accent 边框 */
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm, 8px); gap: 6px;
padding: var(--spacing-sm, 8px); padding: 6px;
background: #fff; border: 1px solid rgba(94, 62, 73, 0.11);
border-radius: var(--radius-full, 999px); border-radius: var(--radius-full, 999px);
border: 1px solid transparent; background: rgba(255, 255, 255, 0.94);
transition: border-color 0.2s ease; box-shadow:
0 12px 30px rgba(75, 48, 57, 0.1),
0 2px 8px rgba(75, 48, 57, 0.05);
backdrop-filter: blur(18px);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
} }
.rowFocused { .rowFocused {
border-color: var(--color-accent, #f84d96); border-color: rgba(246, 87, 160, 0.62);
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 0 0 3px rgba(246, 87, 160, 0.1),
0 14px 34px rgba(92, 52, 67, 0.12);
}
@media (max-width: 350px) {
.bar {
padding-right: calc(12px + var(--app-safe-right, 0px));
padding-left: calc(12px + var(--app-safe-left, 0px));
}
} }
+84 -5
View File
@@ -1,17 +1,25 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPendingChatPromotion } from "@/lib/navigation/chat_unlock_session";
import { recordTipOfferAction } from "@/lib/commercial/tip_offer_session";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance"; import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics"; import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics";
import { ChatComposerActionMenu } from "./chat-composer-action-menu";
import { ChatInputTextField } from "./chat-input-text-field"; import { ChatInputTextField } from "./chat-input-text-field";
import { ChatSendButton } from "./chat-send-button"; import { ChatSendButton } from "./chat-send-button";
import styles from "./chat-input-bar.module.css"; import styles from "./chat-input-bar.module.css";
const log = new Logger("AppChatComponentsChatInputBar"); const log = new Logger("AppChatComponentsChatInputBar");
const POINTER_SEND_CLICK_DEDUPE_MS = 500; const POINTER_SEND_CLICK_DEDUPE_MS = 500;
const CHAT_ACTION_MENU_ID = "chat-composer-action-menu";
export interface ChatInputBarProps { export interface ChatInputBarProps {
disabled?: boolean; disabled?: boolean;
@@ -19,22 +27,57 @@ export interface ChatInputBarProps {
export function ChatInputBar({ disabled = false }: ChatInputBarProps) { export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const dispatch = useChatDispatch(); const dispatch = useChatDispatch();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
const [previousDisabled, setPreviousDisabled] = useState(disabled);
const barRef = useRef<HTMLDivElement>(null); const barRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const lastPointerSendAtRef = useRef(0); const lastPointerSendAtRef = useRef(0);
const hasContent = input.trim().length > 0; const hasContent = input.trim().length > 0;
if (disabled !== previousDisabled) {
setPreviousDisabled(disabled);
if (disabled && isActionMenuOpen) setIsActionMenuOpen(false);
}
useChatKeyboardAvoidance({ useChatKeyboardAvoidance({
active: isFocused, active: isFocused,
containerRef: barRef, containerRef: barRef,
}); });
useChatKeyboardDiagnostics({ containerRef: barRef }); useChatKeyboardDiagnostics({ containerRef: barRef });
useEffect(() => {
if (!isActionMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && barRef.current?.contains(target)) return;
setIsActionMenuOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsActionMenuOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isActionMenuOpen]);
const handleInputChange = (value: string) => { const handleInputChange = (value: string) => {
setInput(value); setInput(value);
if (value.trim().length > 0) setIsActionMenuOpen(false);
};
const handleFocusChange = (focused: boolean) => {
setIsFocused(focused);
if (focused) setIsActionMenuOpen(false);
}; };
const handleSend = (source: "click" | "keyboard" | "pointerdown") => { const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
@@ -62,6 +105,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
}); });
dispatch({ type: "ChatSendMessage", content: input }); dispatch({ type: "ChatSendMessage", content: input });
setInput(""); setInput("");
setIsActionMenuOpen(false);
textareaRef.current?.focus(); textareaRef.current?.focus();
}; };
@@ -70,27 +114,62 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
handleSend("pointerdown"); handleSend("pointerdown");
}; };
const handleMenuToggle = () => {
if (disabled || hasContent) return;
if (!isActionMenuOpen) textareaRef.current?.blur();
setIsActionMenuOpen((open) => !open);
};
const handlePromotion = (promotionType: "image" | "voice") => {
if (disabled) return;
dispatch({
type: "ChatPromotionInjected",
promotion: createPendingChatPromotion(
promotionType,
character.id,
),
});
setIsActionMenuOpen(false);
};
return ( return (
<div ref={barRef} className={styles.bar}>
<div className={styles.composer}>
{isActionMenuOpen ? (
<ChatComposerActionMenu
id={CHAT_ACTION_MENU_ID}
disabled={disabled}
tipHref={characterRoutes.tip}
onImage={() => handlePromotion("image")}
onVoice={() => handlePromotion("voice")}
onNavigate={() => {
recordTipOfferAction(character.id, "opened", "manualMenu");
setIsActionMenuOpen(false);
}}
/>
) : null}
<div <div
ref={barRef} className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}
> >
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
<ChatInputTextField <ChatInputTextField
ref={textareaRef} ref={textareaRef}
value={input} value={input}
onChange={handleInputChange} onChange={handleInputChange}
onSubmit={() => handleSend("keyboard")} onSubmit={() => handleSend("keyboard")}
onFocusChange={setIsFocused} onFocusChange={handleFocusChange}
disabled={disabled} disabled={disabled}
/> />
<ChatSendButton <ChatSendButton
disabled={disabled} disabled={disabled}
hasContent={hasContent} hasContent={hasContent}
isMenuOpen={isActionMenuOpen}
menuId={CHAT_ACTION_MENU_ID}
onClick={() => handleSend("click")} onClick={() => handleSend("click")}
onMenuToggle={handleMenuToggle}
onPointerDownSend={handlePointerDownSend} onPointerDownSend={handlePointerDownSend}
/> />
</div> </div>
</div> </div>
</div>
); );
} }
@@ -59,7 +59,7 @@ export const ChatInputTextField = forwardRef<
}; };
return ( return (
<div className="flex min-w-0 flex-auto items-center rounded-full bg-white px-(--spacing-lg,16px)"> <div className="flex min-w-0 flex-auto items-center rounded-full bg-transparent px-[clamp(10px,3vw,14px)]">
<textarea <textarea
ref={innerRef} ref={innerRef}
className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)" className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)"
+33 -15
View File
@@ -1,29 +1,43 @@
"use client"; "use client";
import { ArrowUp } from "lucide-react"; import { ArrowUp, Plus, X } from "lucide-react";
export interface ChatSendButtonProps { export interface ChatSendButtonProps {
disabled: boolean; disabled: boolean;
hasContent: boolean; hasContent: boolean;
isMenuOpen: boolean;
menuId: string;
onClick: () => void; onClick: () => void;
onMenuToggle: () => void;
onPointerDownSend: () => void; onPointerDownSend: () => void;
} }
export function ChatSendButton({ export function ChatSendButton({
disabled, disabled,
hasContent, hasContent,
isMenuOpen,
menuId,
onClick, onClick,
onMenuToggle,
onPointerDownSend, onPointerDownSend,
}: ChatSendButtonProps) { }: ChatSendButtonProps) {
const isActive = hasContent && !disabled; const isSendMode = hasContent;
const label = isSendMode
? "Send message"
: isMenuOpen
? "Close chat actions"
: "Open chat actions";
return ( return (
<button <button
type="button" type="button"
data-analytics-ignore data-analytics-ignore={isSendMode ? true : undefined}
data-analytics-key={isSendMode ? undefined : "chat.toggle_actions"}
className={[ className={[
"flex aspect-square size-(--chat-send-button-size,40px) shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-(--color-button-gradient-end,#fc69df) text-white transition-[background,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", "flex aspect-square size-(--chat-send-button-size,42px) shrink-0 cursor-pointer items-center justify-center rounded-full border transition-[background,color,transform,box-shadow] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f657a0]",
isActive isSendMode
? "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]" ? "border-transparent bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-white shadow-[0_8px_20px_rgba(246,87,160,0.3)]"
: "bg-[#f8a8ce] text-[rgba(255,255,255,0.88)] shadow-none", : isMenuOpen
? "border-transparent bg-[#38262d] text-white shadow-[0_8px_18px_rgba(56,38,45,0.18)]"
: "border-[rgba(104,67,80,0.09)] bg-[#f8f1f4] text-[#76505f] shadow-none",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
@@ -32,17 +46,21 @@ export function ChatSendButton({
if (event.pointerType === "mouse") return; if (event.pointerType === "mouse") return;
if (disabled) return; if (disabled) return;
event.preventDefault(); event.preventDefault();
if (!hasContent) return; if (!isSendMode) return;
onPointerDownSend(); onPointerDownSend();
}} }}
onClick={onClick} onClick={isSendMode ? onClick : onMenuToggle}
aria-label="Send message" aria-label={label}
aria-expanded={isSendMode ? undefined : isMenuOpen}
aria-controls={isSendMode ? undefined : menuId}
> >
<ArrowUp {isSendMode ? (
className="size-(--icon-size-xl,24px) text-(length:--icon-size-xl,24px) leading-none" <ArrowUp size={23} strokeWidth={2.4} aria-hidden="true" />
size={24} ) : isMenuOpen ? (
aria-hidden="true" <X size={22} strokeWidth={2.2} aria-hidden="true" />
/> ) : (
<Plus size={23} strokeWidth={2.2} aria-hidden="true" />
)}
</button> </button>
); );
} }
@@ -0,0 +1,63 @@
.card {
position: relative;
width: min(100%, 286px);
padding: 12px 42px 12px 13px;
border: 1px solid rgba(122, 91, 73, 0.2);
border-radius: 8px;
background: rgba(255, 250, 246, 0.96);
color: #352a25;
box-shadow: 0 5px 18px rgba(68, 47, 37, 0.08);
}
.card p {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.dismiss {
position: absolute;
top: 7px;
right: 7px;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: #756660;
cursor: pointer;
}
.dismiss:hover,
.dismiss:focus-visible {
background: rgba(104, 82, 70, 0.1);
}
.cta {
display: inline-flex;
min-height: 36px;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 12px;
border-radius: 7px;
background: #765142;
color: #fff;
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.cta span {
min-width: 0;
overflow-wrap: anywhere;
}
.cta:hover,
.cta:focus-visible {
background: #604035;
}
@@ -0,0 +1,58 @@
"use client";
import { Coffee, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import type { CommercialAction } from "@/data/schemas/chat";
import { recordTipOfferAction } from "@/lib/commercial/tip_offer_session";
import { useActiveCharacterRoutes } from "@/providers/character-provider";
import styles from "./commercial-action-card.module.css";
export interface CommercialActionCardProps {
action: CommercialAction;
characterId: string;
}
export function CommercialActionCard({
action,
characterId,
}: CommercialActionCardProps) {
const routes = useActiveCharacterRoutes();
const [dismissed, setDismissed] = useState(false);
if (dismissed || action.type !== "giftOffer") return null;
return (
<aside
className={styles.card}
aria-label="Gift invitation"
data-commercial-action-id={action.actionId}
>
<button
type="button"
className={styles.dismiss}
aria-label="Dismiss gift invitation"
title="Dismiss"
onClick={() => {
setDismissed(true);
recordTipOfferAction(characterId, "dismissed", "aiContext");
}}
>
<X size={16} aria-hidden="true" />
</button>
<p>{action.copy}</p>
<Link
href={routes.tip}
className={styles.cta}
onClick={() =>
recordTipOfferAction(characterId, "opened", "aiContext")
}
>
<Coffee size={17} aria-hidden="true" />
<span>{action.ctaLabel}</span>
</Link>
</aside>
);
}
+10 -39
View File
@@ -1,61 +1,32 @@
"use client"; "use client";
import Image from "next/image";
import { CharacterAvatar, UserMessageAvatar } from "@/app/_components"; import { UserMessageAvatar } from "@/app/_components";
import { useActiveCharacter } from "@/providers/character-provider"; import { useActiveCharacter } from "@/providers/character-provider";
export interface MessageAvatarProps { export interface MessageAvatarProps {
isFromAI: boolean; isFromAI: boolean;
userAvatarUrl?: string | null; userAvatarUrl?: string | null;
onClick?: () => void;
} }
const AVATAR_CLASS_NAME = const AVATAR_CLASS_NAME =
"size-(--chat-avatar-size,43px) ring-2 ring-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))"; "flex size-(--chat-avatar-size,43px) shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
export function MessageAvatar({ export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
isFromAI,
userAvatarUrl,
onClick,
}: MessageAvatarProps) {
const character = useActiveCharacter(); const character = useActiveCharacter();
if (isFromAI) { if (isFromAI) {
if (onClick) {
return ( return (
<CharacterAvatar <div className={AVATAR_CLASS_NAME} aria-label="AI avatar">
<Image
src={character.assets.avatar} src={character.assets.avatar}
alt={character.displayName} alt={character.displayName}
size="var(--chat-avatar-size, 43px)" width={43}
imageSize={43} height={43}
priority priority
className={AVATAR_CLASS_NAME} className="size-full object-cover"
actionLabel={`Open ${character.displayName}'s private room`}
analyticsKey="chat.open_private_room_from_avatar"
onClick={onClick}
/>
);
}
return (
<CharacterAvatar
src={character.assets.avatar}
alt={character.displayName}
size="var(--chat-avatar-size, 43px)"
imageSize={43}
priority
className={AVATAR_CLASS_NAME}
/>
);
}
if (onClick) {
return (
<UserMessageAvatar
avatarUrl={userAvatarUrl}
actionLabel="Open profile"
analyticsKey="chat.open_profile_from_avatar"
onClick={onClick}
/> />
</div>
); );
} }
+7 -13
View File
@@ -9,6 +9,7 @@
* - 用户:[占位 spacer] [Content] [Avatar] * - 用户:[占位 spacer] [Content] [Avatar]
*/ */
import { useUserSelector } from "@/stores/user/user-context"; import { useUserSelector } from "@/stores/user/user-context";
import type { CommercialAction } from "@/data/schemas/chat";
import { MessageAvatar } from "./message-avatar"; import { MessageAvatar } from "./message-avatar";
import { MessageContent } from "./message-content"; import { MessageContent } from "./message-content";
@@ -32,8 +33,7 @@ export interface MessageBubbleProps {
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction; onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (displayMessageId: string) => void;
onUserAvatarClick?: () => void; commercialAction?: CommercialAction | null;
onCharacterAvatarClick?: () => void;
} }
type ChatMessageAction = ( type ChatMessageAction = (
@@ -59,8 +59,7 @@ export function MessageBubble({
onUnlockVoiceMessage, onUnlockVoiceMessage,
onUnlockImageMessage, onUnlockImageMessage,
onOpenImage, onOpenImage,
onUserAvatarClick, commercialAction,
onCharacterAvatarClick,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const avatarUrl = useUserSelector((state) => state.context.avatarUrl); const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
@@ -71,10 +70,7 @@ export function MessageBubble({
data-chat-message-id={displayMessageId} data-chat-message-id={displayMessageId}
aria-label="AI message" aria-label="AI message"
> >
<MessageAvatar <MessageAvatar isFromAI={true} />
isFromAI={true}
onClick={onCharacterAvatarClick}
/>
<div className={styles.bubbleInlineSpacer} aria-hidden="true" /> <div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent <MessageContent
characterId={characterId} characterId={characterId}
@@ -94,6 +90,7 @@ export function MessageBubble({
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage} onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
commercialAction={commercialAction}
/> />
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" /> <div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div> </div>
@@ -125,13 +122,10 @@ export function MessageBubble({
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage} onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
commercialAction={commercialAction}
/> />
<div className={styles.bubbleInlineSpacer} aria-hidden="true" /> <div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageAvatar <MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
isFromAI={false}
userAvatarUrl={avatarUrl}
onClick={onUserAvatarClick}
/>
</div> </div>
); );
} }
@@ -1,4 +1,7 @@
"use client"; "use client";
import type { CommercialAction } from "@/data/schemas/chat";
import { CommercialActionCard } from "./commercial-action-card";
import { ImageBubble } from "./image-bubble"; import { ImageBubble } from "./image-bubble";
import { LockedImageMessageCard } from "./locked-image-message-card"; import { LockedImageMessageCard } from "./locked-image-message-card";
import { PrivateMessageCard } from "./private-message-card"; import { PrivateMessageCard } from "./private-message-card";
@@ -24,6 +27,7 @@ export interface MessageContentProps {
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction; onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (displayMessageId: string) => void;
commercialAction?: CommercialAction | null;
} }
type ChatMessageAction = ( type ChatMessageAction = (
@@ -51,6 +55,7 @@ export function MessageContent({
onUnlockVoiceMessage, onUnlockVoiceMessage,
onUnlockImageMessage, onUnlockImageMessage,
onOpenImage, onOpenImage,
commercialAction,
}: MessageContentProps) { }: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0; const hasImage = imageUrl != null && imageUrl.length > 0;
const hasAudio = audioUrl != null && audioUrl.length > 0; const hasAudio = audioUrl != null && audioUrl.length > 0;
@@ -125,6 +130,12 @@ export function MessageContent({
) : null} ) : null}
</> </>
)} )}
{isFromAI && commercialAction ? (
<CommercialActionCard
action={commercialAction}
characterId={characterId}
/>
) : null}
</div> </div>
); );
} }
@@ -22,14 +22,14 @@ vi.mock("@/stores/user/user-context", () => ({
})); }));
describe("CoinsRulesScreen", () => { describe("CoinsRulesScreen", () => {
it("renders outside CharacterProvider and returns to global profile", () => { it("renders outside CharacterProvider and returns to global sidebar", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<CoinsRulesScreen returnTo="/characters/maya/chat" />, <CoinsRulesScreen returnTo="/characters/maya/chat" />,
); );
expect(html).toContain("Coin Usage Rules"); expect(html).toContain("Coin Usage Rules");
expect(html).toContain( expect(html).toContain(
'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"', 'href="/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
); );
}); });
}); });
+2 -2
View File
@@ -118,9 +118,9 @@ export function CoinsRulesScreen({ returnTo }: CoinsRulesScreenProps) {
<main className={styles.screen}> <main className={styles.screen}>
<header className={styles.header}> <header className={styles.header}>
<BackButton <BackButton
href={navigation.profileUrl} href={navigation.sidebarUrl}
variant="soft" variant="soft"
aria-label="Back to profile" aria-label="Back to sidebar"
/> />
<div className={styles.hero}> <div className={styles.hero}>
@@ -69,9 +69,9 @@ describe("FeedbackScreen", () => {
).toBe("feedback-content-hint"); ).toBe("feedback-content-hint");
expect( expect(
container.querySelector<HTMLAnchorElement>( container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="feedback.back_to_profile"]', '[data-analytics-key="feedback.back_to_sidebar"]',
)?.getAttribute("href"), )?.getAttribute("href"),
).toBe("/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"); ).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 () => {
@@ -106,7 +106,7 @@ 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 Profile"); expect(container.textContent).toContain("Back to Sidebar");
}); });
}); });
+6 -6
View File
@@ -77,11 +77,11 @@ export function FeedbackScreen({ returnTo }: FeedbackScreenProps) {
</div> </div>
<button <button
type="button" type="button"
data-analytics-key="feedback.back_to_profile" data-analytics-key="feedback.back_to_sidebar"
className={styles.primaryButton} className={styles.primaryButton}
onClick={() => navigator.replace(navigation.profileUrl)} onClick={() => navigator.replace(navigation.sidebarUrl)}
> >
Back to Profile Back to Sidebar
</button> </button>
</section> </section>
</main> </main>
@@ -102,10 +102,10 @@ export function FeedbackScreen({ returnTo }: FeedbackScreenProps) {
<header className={styles.header}> <header className={styles.header}>
<BackButton <BackButton
href={navigation.profileUrl} href={navigation.sidebarUrl}
variant="soft" variant="soft"
ariaLabel="Back to profile" ariaLabel="Back to sidebar"
analyticsKey="feedback.back_to_profile" 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
View File
@@ -1,4 +1,5 @@
export * from "./private-album-card"; export * from "./private-album-card";
export * from "./private-album-gallery"; export * from "./private-album-gallery";
export * from "./relationship-diary-panel";
export * from "./status-card"; export * from "./status-card";
export * from "./unlock-confirm-dialog"; export * from "./unlock-confirm-dialog";
@@ -0,0 +1,239 @@
.section {
padding: 18px 18px 104px;
}
.section[hidden] {
display: none;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.header h2 {
margin: 2px 0 0;
color: #252a28;
font-size: 20px;
line-height: 1.25;
}
.kicker {
margin: 0;
color: #7c6057;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.balance {
display: inline-flex;
min-height: 34px;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid #d8d5ca;
border-radius: 7px;
background: #fff;
color: #4f554f;
font-size: 13px;
font-weight: 750;
}
.timeline {
display: grid;
gap: 14px;
}
.diary {
overflow: hidden;
padding: 16px;
border: 1px solid #dcddd8;
border-radius: 8px;
background: #fff;
box-shadow: 0 5px 18px rgba(44, 49, 46, 0.06);
}
.diary[data-locked="true"] {
background: #fffdfb;
}
.diaryMeta {
display: flex;
min-height: 22px;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #727873;
font-size: 12px;
}
.unread {
padding: 3px 7px;
border-radius: 999px;
background: #9f3348;
color: #fff;
font-size: 10px;
font-weight: 800;
}
.diary h3 {
margin: 8px 0 7px;
color: #232724;
font-size: 17px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.teaser {
margin: 0;
color: #626864;
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.unlockButton,
.loadMore,
.primaryButton,
.secondaryButton {
display: inline-flex;
min-height: 40px;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: 7px;
font-weight: 750;
cursor: pointer;
}
.unlockButton {
width: 100%;
margin-top: 14px;
border: 1px solid #b7a091;
background: #f8f0ea;
color: #5d4135;
}
.unlockedContent {
margin-top: 14px;
}
.diaryImage {
display: block;
width: 100%;
height: auto;
max-height: 560px;
border-radius: 7px;
object-fit: cover;
}
.unlockedContent p {
margin: 14px 0 0;
color: #363b38;
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.loadMore {
width: 100%;
margin-top: 14px;
border: 1px solid #cfd2cc;
background: #fff;
color: #4f5651;
}
.loadMore svg[data-spinning="true"] {
animation: diary-spin 0.8s linear infinite;
}
.dialogBackdrop {
position: fixed;
z-index: 70;
inset: 0;
display: grid;
place-items: end center;
padding: 18px;
background: rgba(25, 28, 26, 0.48);
}
.dialog {
width: min(100%, 460px);
padding: 20px;
border-radius: 8px;
background: #fff;
box-shadow: 0 18px 54px rgba(24, 27, 25, 0.24);
}
.dialogIcon {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 50%;
background: #edf1ed;
color: #536359;
}
.dialog h2 {
margin: 14px 0 7px;
color: #262b28;
font-size: 19px;
}
.dialog p {
margin: 0;
color: #626864;
font-size: 14px;
line-height: 1.5;
}
.dialogMessage {
margin-top: 11px !important;
color: #8c3044 !important;
}
.dialogActions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 18px;
}
.secondaryButton {
border: 1px solid #d0d3ce;
background: #fff;
color: #4f5651;
}
.primaryButton {
border: 1px solid #58685e;
background: #58685e;
color: #fff;
}
.unlockButton:disabled,
.loadMore:disabled,
.primaryButton:disabled,
.secondaryButton:disabled {
cursor: default;
opacity: 0.58;
}
@keyframes diary-spin {
to {
transform: rotate(360deg);
}
}
@media (min-width: 541px) {
.dialogBackdrop {
place-items: center;
}
}
@@ -0,0 +1,354 @@
"use client";
import { BookHeart, Coins, LockKeyhole, RefreshCw } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import type {
RelationshipDiariesResponse,
RelationshipDiary,
} from "@/data/schemas/private-room";
import { privateRoomApi } from "@/data/services/api/private_room_api";
import { StatusCard } from "./status-card";
import styles from "./relationship-diary-panel.module.css";
export interface RelationshipDiaryPanelProps {
active: boolean;
characterId: string;
displayName: string;
enabled: boolean;
registered: boolean;
onRequireAuth: () => void;
onTopUp: () => void;
onUnreadCountChange: (count: number) => void;
}
export function RelationshipDiaryPanel({
active,
characterId,
displayName,
enabled,
registered,
onRequireAuth,
onTopUp,
onUnreadCountChange,
}: RelationshipDiaryPanelProps) {
const [data, setData] = useState<RelationshipDiariesResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pendingDiary, setPendingDiary] = useState<RelationshipDiary | null>(
null,
);
const [isUnlocking, setIsUnlocking] = useState(false);
const [unlockMessage, setUnlockMessage] = useState<string | null>(null);
const load = useCallback(
async (cursor?: string | null) => {
if (!enabled) return;
if (cursor) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
}
setErrorMessage(null);
try {
const response = await privateRoomApi.getDiaries({
characterId,
limit: 20,
cursor,
});
setData((current) =>
cursor && current
? {
...response,
items: [...current.items, ...response.items],
}
: response,
);
onUnreadCountChange(response.unreadCount);
if (registered) {
const unseen = response.items.filter((item) => !item.seen);
if (unseen.length > 0) {
void Promise.allSettled(
unseen.map((item) =>
privateRoomApi.markDiarySeen(item.diaryId),
),
).then(() => {
setData((current) =>
current
? {
...current,
unreadCount: 0,
items: current.items.map((item) => ({
...item,
seen: true,
})),
}
: current,
);
onUnreadCountChange(0);
});
}
}
} catch (error) {
setErrorMessage(toMessage(error));
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
},
[characterId, enabled, onUnreadCountChange, registered],
);
useEffect(() => {
if (!enabled) return;
const timeoutId = globalThis.setTimeout(() => void load(), 0);
return () => globalThis.clearTimeout(timeoutId);
}, [characterId, enabled, load, registered]);
const requestUnlock = (diary: RelationshipDiary) => {
if (!registered) {
onRequireAuth();
return;
}
setUnlockMessage(null);
setPendingDiary(diary);
};
const confirmUnlock = async () => {
if (!pendingDiary || isUnlocking) return;
setIsUnlocking(true);
setUnlockMessage(null);
try {
const response = await privateRoomApi.unlockDiary(
pendingDiary.diaryId,
{ expectedCost: pendingDiary.unlockCostCredits },
);
if (response.unlocked) {
if (response.diary) {
setData((current) =>
current
? {
...current,
creditBalance: response.creditBalance,
items: current.items.map((item) =>
item.diaryId === response.diary?.diaryId
? response.diary
: item,
),
}
: current,
);
} else {
await load();
}
setPendingDiary(null);
return;
}
if (response.reason === "cost_changed") {
const nextCost = response.requiredCredits;
setPendingDiary((current) =>
current ? { ...current, unlockCostCredits: nextCost } : current,
);
setData((current) =>
current
? {
...current,
items: current.items.map((item) =>
item.diaryId === pendingDiary.diaryId
? { ...item, unlockCostCredits: nextCost }
: item,
),
}
: current,
);
setUnlockMessage(`The unlock price changed to ${nextCost} credits.`);
} else if (response.reason === "insufficient_credits") {
setUnlockMessage(
`You need ${response.shortfallCredits} more credits to unlock this diary.`,
);
} else if (response.reason === "not_found") {
setUnlockMessage("This diary is no longer available.");
} else {
setUnlockMessage("The unlock could not be completed. Please try again.");
}
} catch (error) {
setUnlockMessage(toMessage(error));
} finally {
setIsUnlocking(false);
}
};
return (
<section
className={styles.section}
aria-labelledby="diaries-title"
hidden={!active}
>
<div className={styles.header}>
<div>
<p className={styles.kicker}>Diaries</p>
<h2 id="diaries-title">Notes from {displayName}</h2>
</div>
<span className={styles.balance} aria-label="Credit balance">
<Coins size={16} aria-hidden="true" />
{data?.creditBalance ?? 0}
</span>
</div>
{!registered && !isLoading ? (
<StatusCard
message="Sign in to read and unlock your personal diaries."
actionLabel="Sign in"
onAction={onRequireAuth}
/>
) : null}
{errorMessage ? (
<StatusCard
message={errorMessage}
actionLabel="Retry"
onAction={() => void load()}
/>
) : null}
{isLoading && !data ? <StatusCard message="Loading diaries..." /> : null}
{registered && !isLoading && data?.items.length === 0 && !errorMessage ? (
<StatusCard message="No diary has been written yet." />
) : null}
<div className={styles.timeline}>
{data?.items.map((diary) => (
<article
key={diary.diaryId}
className={styles.diary}
data-locked={diary.locked ? "true" : "false"}
>
<div className={styles.diaryMeta}>
<span>{formatDiaryDate(diary.diaryDate)}</span>
{!diary.seen ? <span className={styles.unread}>New</span> : null}
</div>
<h3>{diary.title}</h3>
<p className={styles.teaser}>{diary.teaserText}</p>
{diary.locked ? (
<button
type="button"
className={styles.unlockButton}
onClick={() => requestUnlock(diary)}
>
<LockKeyhole size={17} aria-hidden="true" />
<span>Unlock for {diary.unlockCostCredits} credits</span>
</button>
) : (
<div className={styles.unlockedContent}>
{diary.imageUrl ? (
<Image
src={diary.imageUrl}
alt=""
width={680}
height={850}
sizes="(max-width: 540px) 92vw, 480px"
className={styles.diaryImage}
unoptimized
/>
) : null}
<p>{diary.content}</p>
</div>
)}
</article>
))}
</div>
{data?.nextCursor ? (
<button
type="button"
className={styles.loadMore}
disabled={isLoadingMore}
onClick={() => void load(data.nextCursor)}
>
<RefreshCw
size={16}
aria-hidden="true"
data-spinning={isLoadingMore ? "true" : "false"}
/>
<span>{isLoadingMore ? "Loading..." : "Load earlier diaries"}</span>
</button>
) : null}
{pendingDiary ? (
<div className={styles.dialogBackdrop} role="presentation">
<section
className={styles.dialog}
role="dialog"
aria-modal="true"
aria-labelledby="diary-unlock-title"
>
<span className={styles.dialogIcon} aria-hidden="true">
<BookHeart size={23} />
</span>
<h2 id="diary-unlock-title">Unlock this diary?</h2>
<p>
{pendingDiary.unlockCostCredits} credits will be deducted from
your balance.
</p>
{unlockMessage ? (
<p className={styles.dialogMessage} role="status">
{unlockMessage}
</p>
) : null}
<div className={styles.dialogActions}>
<button
type="button"
className={styles.secondaryButton}
disabled={isUnlocking}
onClick={() => setPendingDiary(null)}
>
Cancel
</button>
{unlockMessage?.includes("more credits") ? (
<button
type="button"
className={styles.primaryButton}
onClick={onTopUp}
>
Get credits
</button>
) : (
<button
type="button"
className={styles.primaryButton}
disabled={isUnlocking}
onClick={() => void confirmUnlock()}
>
{isUnlocking ? "Unlocking..." : "Unlock"}
</button>
)}
</div>
</section>
</div>
) : null}
</section>
);
}
function formatDiaryDate(value: string | null): string {
if (!value) return "Private diary";
const parsed = new Date(`${value}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(parsed);
}
function toMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return "Something went wrong. Please try again.";
}
@@ -48,6 +48,61 @@
z-index: 1; z-index: 1;
} }
.postsSection[hidden] {
display: none;
}
.collectionTabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin:
20px
calc(var(--app-safe-right, 0px) + 18px)
0
calc(var(--app-safe-left, 0px) + 18px);
padding: 4px;
border: 1px solid #d8dad5;
border-radius: 8px;
background: #e8ebe6;
}
.collectionTabs button {
position: relative;
display: inline-flex;
min-width: 0;
min-height: 38px;
align-items: center;
justify-content: center;
gap: 7px;
border: 0;
border-radius: 6px;
background: transparent;
color: #646a65;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.collectionTabs button[data-active="true"] {
background: #fff;
color: #2b302d;
box-shadow: 0 2px 8px rgba(44, 49, 46, 0.09);
}
.tabBadge {
display: inline-grid;
min-width: 19px;
height: 19px;
place-items: center;
padding: 0 5px;
border-radius: 999px;
background: #a4364c;
color: #fff;
font-size: 10px;
line-height: 1;
}
.hero { .hero {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+46 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
@@ -21,6 +21,7 @@ import {
import { import {
PrivateAlbumCard, PrivateAlbumCard,
PrivateAlbumGallery, PrivateAlbumGallery,
RelationshipDiaryPanel,
StatusCard, StatusCard,
UnlockConfirmDialog, UnlockConfirmDialog,
} from "./components"; } from "./components";
@@ -48,6 +49,10 @@ export function PrivateRoomScreen() {
const state = usePrivateRoomState(); const state = usePrivateRoomState();
const dispatch = usePrivateRoomDispatch(); const dispatch = usePrivateRoomDispatch();
const openedGalleryInPageRef = useRef(false); const openedGalleryInPageRef = useRef(false);
const [activeCollection, setActiveCollection] = useState<
"albums" | "diaries"
>("albums");
const [diaryUnreadCount, setDiaryUnreadCount] = useState(0);
const galleryState = useMemo( const galleryState = useMemo(
() => getPrivateAlbumGalleryState(searchParams), () => getPrivateAlbumGalleryState(searchParams),
[searchParams], [searchParams],
@@ -207,7 +212,35 @@ export function PrivateRoomScreen() {
</button> </button>
</section> </section>
<section className={styles.postsSection} aria-labelledby="posts-title"> <div className={styles.collectionTabs} aria-label="Private room views">
<button
type="button"
aria-pressed={activeCollection === "albums"}
data-active={activeCollection === "albums" ? "true" : "false"}
onClick={() => setActiveCollection("albums")}
>
Albums
</button>
<button
type="button"
aria-pressed={activeCollection === "diaries"}
data-active={activeCollection === "diaries" ? "true" : "false"}
onClick={() => setActiveCollection("diaries")}
>
Diaries
{diaryUnreadCount > 0 ? (
<span className={styles.tabBadge} aria-label={`${diaryUnreadCount} new`}>
{Math.min(diaryUnreadCount, 99)}
</span>
) : null}
</button>
</div>
<section
className={styles.postsSection}
aria-labelledby="posts-title"
hidden={activeCollection !== "albums"}
>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<div> <div>
<p className={styles.kicker}>Albums</p> <p className={styles.kicker}>Albums</p>
@@ -259,6 +292,17 @@ export function PrivateRoomScreen() {
</div> </div>
</section> </section>
<RelationshipDiaryPanel
active={activeCollection === "diaries"}
characterId={character.id}
displayName={displayName}
enabled={authState.hasInitialized}
registered={!isPrivateRoomAuthRequired(authState.loginStatus)}
onRequireAuth={() => navigator.openAuth(characterRoutes.privateRoom)}
onTopUp={handleTopUpClick}
onUnreadCountChange={setDiaryUnreadCount}
/>
<AppBottomNav <AppBottomNav
activeItem="privateRoom" activeItem="privateRoom"
privateRoomLabel={character.copy.privateRoomTitle} privateRoomLabel={character.copy.privateRoomTitle}
@@ -4,7 +4,7 @@ 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 { ProfileScreen } from "../profile-screen"; import { SidebarScreen } from "../sidebar-screen";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
openAuth: vi.fn(), openAuth: vi.fn(),
@@ -59,11 +59,11 @@ vi.mock("../use-pwa-install-entry", () => ({
installApp: vi.fn(), installApp: vi.fn(),
}), }),
})); }));
vi.mock("../use-profile-user-bootstrap", () => ({ vi.mock("../use-sidebar-user-bootstrap", () => ({
useProfileUserBootstrap: vi.fn(), useSidebarUserBootstrap: vi.fn(),
})); }));
describe("ProfileScreen", () => { describe("SidebarScreen", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
let root: Root; let root: Root;
@@ -83,12 +83,12 @@ describe("ProfileScreen", () => {
it("renders without CharacterProvider and preserves the source character", () => { it("renders without CharacterProvider and preserves the source character", () => {
act(() => { act(() => {
root.render(<ProfileScreen returnTo="/characters/maya/chat" />); root.render(<SidebarScreen returnTo="/characters/maya/chat" />);
}); });
expect( expect(
container.querySelector<HTMLAnchorElement>( container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="profile.back_to_chat"]', '[data-analytics-key="sidebar.back_to_chat"]',
)?.getAttribute("href"), )?.getAttribute("href"),
).toBe("/characters/maya/chat"); ).toBe("/characters/maya/chat");
@@ -107,7 +107,7 @@ describe("ProfileScreen", () => {
expect.objectContaining({ expect.objectContaining({
type: "topup", type: "topup",
sourceCharacterSlug: "maya", sourceCharacterSlug: "maya",
returnTo: "profile", returnTo: "sidebar",
}), }),
); );
}); });
@@ -3,10 +3,10 @@ import { describe, expect, it } from "vitest";
import type { UserView } from "@/stores/user/user-view"; import type { UserView } from "@/stores/user/user-view";
import { import {
getProfileViewModel, getSidebarViewModel,
getProfileWalletView, getSidebarWalletView,
normalizeProfileCount, normalizeSidebarCount,
} from "../profile-view-model"; } from "../sidebar-view-model";
const baseUser: UserView = { const baseUser: UserView = {
id: "user-1", id: "user-1",
@@ -20,9 +20,9 @@ const baseUser: UserView = {
isVip: false, isVip: false,
}; };
describe("getProfileViewModel", () => { describe("getSidebarViewModel", () => {
it("derives guest state for logged-out users", () => { it("derives guest state for logged-out users", () => {
const view = getProfileViewModel({ const view = getSidebarViewModel({
loginStatus: "notLoggedIn", loginStatus: "notLoggedIn",
user: { user: {
currentUser: null, currentUser: null,
@@ -37,7 +37,7 @@ describe("getProfileViewModel", () => {
}); });
it("derives member state and shows VIP activation for non-VIP users", () => { it("derives member state and shows VIP activation for non-VIP users", () => {
const view = getProfileViewModel({ const view = getSidebarViewModel({
loginStatus: "email", loginStatus: "email",
user: { user: {
currentUser: baseUser, currentUser: baseUser,
@@ -54,7 +54,7 @@ describe("getProfileViewModel", () => {
}); });
it("derives VIP state and hides VIP activation for VIP users", () => { it("derives VIP state and hides VIP activation for VIP users", () => {
const view = getProfileViewModel({ const view = getSidebarViewModel({
loginStatus: "email", loginStatus: "email",
user: { user: {
currentUser: { ...baseUser, isVip: true }, currentUser: { ...baseUser, isVip: true },
@@ -67,8 +67,8 @@ describe("getProfileViewModel", () => {
expect(view.canActivateVip).toBe(false); expect(view.canActivateVip).toBe(false);
}); });
it("keeps Guest users in the signed-out profile state", () => { it("uses fallback name and marks logged-in users without profile as initializing", () => {
const view = getProfileViewModel({ const view = getSidebarViewModel({
loginStatus: "guest", loginStatus: "guest",
user: { user: {
currentUser: null, currentUser: null,
@@ -77,15 +77,15 @@ describe("getProfileViewModel", () => {
}, },
}); });
expect(view.state).toBe("guest"); expect(view.state).toBe("member");
expect(view.name).toBe("User name"); expect(view.name).toBe("User name");
expect(view.isInitializingUser).toBe(false); expect(view.isInitializingUser).toBe(true);
}); });
}); });
describe("getProfileWalletView", () => { describe("getSidebarWalletView", () => {
it("normalizes missing, negative, and fractional wallet values", () => { it("normalizes missing, negative, and fractional wallet values", () => {
const view = getProfileWalletView({ const view = getSidebarWalletView({
currentUser: { currentUser: {
...baseUser, ...baseUser,
dailyFreeChatLimit: -1, dailyFreeChatLimit: -1,
@@ -107,7 +107,7 @@ describe("getProfileWalletView", () => {
}); });
it("normalizes undefined and null counts to zero", () => { it("normalizes undefined and null counts to zero", () => {
expect(normalizeProfileCount(undefined)).toBe(0); expect(normalizeSidebarCount(undefined)).toBe(0);
expect(normalizeProfileCount(null)).toBe(0); expect(normalizeSidebarCount(null)).toBe(0);
}); });
}); });
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { UserHeader } from "../user-header"; import { UserHeader } from "../user-header";
describe("profile Tailwind components", () => { describe("sidebar Tailwind components", () => {
it("renders guest, member, and VIP header states", () => { it("renders guest, member, and VIP header states", () => {
const guestHtml = renderToStaticMarkup( const guestHtml = renderToStaticMarkup(
<UserHeader <UserHeader
@@ -2,5 +2,5 @@
* @file Automatically generated by barrelsby. * @file Automatically generated by barrelsby.
*/ */
export * from "./profile-wallet-card"; export * from "./sidebar-wallet-card";
export * from "./user-header"; export * from "./user-header";
@@ -2,9 +2,9 @@
import { FaCoins } from "react-icons/fa6"; import { FaCoins } from "react-icons/fa6";
import styles from "./profile-wallet-card.module.css"; import styles from "./sidebar-wallet-card.module.css";
export interface ProfileWalletCardProps { export interface SidebarWalletCardProps {
creditBalance: number; creditBalance: number;
dailyFreeChatLimit: number; dailyFreeChatLimit: number;
dailyFreeChatRemaining: number; dailyFreeChatRemaining: number;
@@ -18,7 +18,7 @@ export interface ProfileWalletCardProps {
const formatCoins = (value: number): string => const formatCoins = (value: number): string =>
new Intl.NumberFormat("en-US").format(Math.max(0, Math.trunc(value))); new Intl.NumberFormat("en-US").format(Math.max(0, Math.trunc(value)));
export function ProfileWalletCard({ export function SidebarWalletCard({
creditBalance, creditBalance,
dailyFreeChatLimit, dailyFreeChatLimit,
dailyFreeChatRemaining, dailyFreeChatRemaining,
@@ -27,7 +27,7 @@ export function ProfileWalletCard({
onActivateVip, onActivateVip,
onTopUp, onTopUp,
onRulesClick, onRulesClick,
}: ProfileWalletCardProps) { }: SidebarWalletCardProps) {
return ( return (
<section className={styles.card} aria-label="My wallet"> <section className={styles.card} aria-label="My wallet">
<div className={styles.leftColumn}> <div className={styles.leftColumn}>
@@ -45,7 +45,7 @@ export function ProfileWalletCard({
<button <button
type="button" type="button"
data-analytics-key="profile.coins_rules" data-analytics-key="sidebar.coins_rules"
data-analytics-label="Open coin usage rules" data-analytics-label="Open coin usage rules"
className={styles.rulesButton} className={styles.rulesButton}
onClick={onRulesClick} onClick={onRulesClick}
@@ -58,7 +58,7 @@ export function ProfileWalletCard({
{onActivateVip ? ( {onActivateVip ? (
<button <button
type="button" type="button"
data-analytics-key="profile.activate_vip" data-analytics-key="sidebar.activate_vip"
data-analytics-label="Activate VIP" data-analytics-label="Activate VIP"
className={styles.activateButton} className={styles.activateButton}
onClick={onActivateVip} onClick={onActivateVip}
@@ -68,7 +68,7 @@ export function ProfileWalletCard({
) : null} ) : null}
<button <button
type="button" type="button"
data-analytics-key="profile.topup" data-analytics-key="sidebar.topup"
data-analytics-label="Top up credits" data-analytics-label="Top up credits"
className={styles.topUpButton} className={styles.topUpButton}
onClick={onTopUp} onClick={onTopUp}
@@ -3,11 +3,11 @@
import Image from "next/image"; import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components"; import { UserMessageAvatar } from "@/app/_components";
import type { ProfileUserState } from "@/app/profile/profile-view-model"; import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model";
export interface UserHeaderProps { export interface UserHeaderProps {
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */ /** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
state: ProfileUserState; state: SidebarUserState;
/** 用户名(无 currentUser 时使用 fallback "User name" */ /** 用户名(无 currentUser 时使用 fallback "User name" */
name: string; name: string;
/** null → 默认头像 SVG */ /** null → 默认头像 SVG */
@@ -36,8 +36,8 @@ export function UserHeader({
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex w-full items-center gap-(--spacing-md,12px)" aria-hidden="true"> <div className="flex w-full items-center gap-(--spacing-md,12px)" aria-hidden="true">
<div className="flex size-(--profile-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white" /> <div className="flex size-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white" />
<div className="flex min-w-0 flex-auto flex-col gap-(--profile-card-gap-y,4px)"> <div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)">
<span className="block h-[clamp(16px,3.333vw,18px)] w-[60%] rounded-md bg-(--color-dark-gray,#e5e5e5)" /> <span className="block h-[clamp(16px,3.333vw,18px)] w-[60%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
<span className="block h-[clamp(12px,2.593vw,14px)] w-[80%] rounded-md bg-(--color-dark-gray,#e5e5e5)" /> <span className="block h-[clamp(12px,2.593vw,14px)] w-[80%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
<span className="block h-[clamp(12px,2.593vw,14px)] w-[40%] rounded-md bg-(--color-dark-gray,#e5e5e5)" /> <span className="block h-[clamp(12px,2.593vw,14px)] w-[40%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
@@ -50,11 +50,11 @@ export function UserHeader({
<div className="flex w-full items-center gap-(--spacing-md,12px)"> <div className="flex w-full items-center gap-(--spacing-md,12px)">
<UserMessageAvatar <UserMessageAvatar
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
className="flex size-(--profile-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white" className="flex size-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white"
size="var(--profile-avatar-size, 56px)" size="var(--sidebar-avatar-size, 56px)"
/> />
<div className="flex min-w-0 flex-auto flex-col gap-(--profile-card-gap-y,4px)"> <div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)">
<p className="m-0 overflow-hidden text-ellipsis whitespace-nowrap text-(length:--font-responsive-md,16px) font-semibold leading-[1.2] text-black"> <p className="m-0 overflow-hidden text-ellipsis whitespace-nowrap text-(length:--font-responsive-md,16px) font-semibold leading-[1.2] text-black">
{name} {name}
</p> </p>
@@ -66,7 +66,7 @@ export function UserHeader({
{state === "vip" && ( {state === "vip" && (
<span className="inline-flex self-start items-center gap-(--spacing-xs,4px) rounded-full bg-(--color-pill-bg,rgba(248,77,150,0.10)) px-[clamp(7px,1.667vw,9px)] py-[clamp(3px,0.741vw,4px)] text-(length:--font-size-xs,10px) font-semibold leading-none text-accent"> <span className="inline-flex self-start items-center gap-(--spacing-xs,4px) rounded-full bg-(--color-pill-bg,rgba(248,77,150,0.10)) px-[clamp(7px,1.667vw,9px)] py-[clamp(3px,0.741vw,4px)] text-(length:--font-size-xs,10px) font-semibold leading-none text-accent">
<Image <Image
src="/images/profile/ic_user_vip.png" src="/images/sidebar/ic_user_vip.png"
alt="" alt=""
width={39} width={39}
height={36} height={36}
@@ -80,7 +80,7 @@ export function UserHeader({
{state === "guest" && ( {state === "guest" && (
<button <button
type="button" type="button"
data-analytics-key="profile.login" data-analytics-key="sidebar.login"
data-analytics-label="Open login" data-analytics-label="Open login"
className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[linear-gradient(90deg,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] px-(--spacing-lg,16px) py-[clamp(5px,1.111vw,6px)] text-(length:clamp(12px,2.593vw,14px)) font-semibold text-(--color-text-primary,#ffffff) hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[linear-gradient(90deg,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] px-(--spacing-lg,16px) py-[clamp(5px,1.111vw,6px)] text-(length:clamp(12px,2.593vw,14px)) font-semibold text-(--color-text-primary,#ffffff) hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
onClick={onLoginClick} onClick={onLoginClick}
@@ -1,12 +1,12 @@
import type { RouteSearchParams } from "@/router/routes"; import type { RouteSearchParams } from "@/router/routes";
import { ProfileScreen } from "./profile-screen"; import { SidebarScreen } from "./sidebar-screen";
export default async function ProfilePage({ export default async function SidebarPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<RouteSearchParams>; searchParams: Promise<RouteSearchParams>;
}) { }) {
const query = await searchParams; const query = await searchParams;
return <ProfileScreen returnTo={query.returnTo} />; return <SidebarScreen returnTo={query.returnTo} />;
} }
@@ -9,23 +9,22 @@ import {
resolveGlobalRouteContext, resolveGlobalRouteContext,
type GlobalReturnToValue, type GlobalReturnToValue,
} from "@/router/global-route-context"; } from "@/router/global-route-context";
import { setPendingLogoutNavigation } from "@/router/logout-navigation";
import { useGlobalAppNavigator } from "@/router/use-global-app-navigator"; 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";
import { ProfileWalletCard, UserHeader } from "./components"; import { SidebarWalletCard, UserHeader } from "./components";
import { getProfileViewModel } from "./profile-view-model"; import { getSidebarViewModel } from "./sidebar-view-model";
import { usePwaInstallEntry } from "./use-pwa-install-entry"; import { usePwaInstallEntry } from "./use-pwa-install-entry";
import { useProfileUserBootstrap } from "./use-profile-user-bootstrap"; import { useSidebarUserBootstrap } from "./use-sidebar-user-bootstrap";
import styles from "./components/profile-screen.module.css"; import styles from "./components/sidebar-screen.module.css";
export interface ProfileScreenProps { export interface SidebarScreenProps {
returnTo?: GlobalReturnToValue; returnTo?: GlobalReturnToValue;
} }
export function ProfileScreen({ returnTo }: ProfileScreenProps) { export function SidebarScreen({ returnTo }: SidebarScreenProps) {
const navigator = useGlobalAppNavigator(); const navigator = useGlobalAppNavigator();
const navigation = resolveGlobalRouteContext(returnTo); const navigation = resolveGlobalRouteContext(returnTo);
const user = useUserState(); const user = useUserState();
@@ -34,17 +33,16 @@ export function ProfileScreen({ returnTo }: ProfileScreenProps) {
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const pwaInstallEntry = usePwaInstallEntry(); const pwaInstallEntry = usePwaInstallEntry();
useProfileUserBootstrap({ auth, userDispatch }); useSidebarUserBootstrap({ auth, userDispatch });
const handleLogoutClick = () => { const handleLogoutClick = () => {
setPendingLogoutNavigation(navigation.splashUrl);
userDispatch({ type: "UserClearLocal" }); userDispatch({ type: "UserClearLocal" });
authDispatch({ type: "AuthLogoutSubmitted" }); authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false }); void signOut({ redirect: false });
navigator.replace(navigation.splashUrl, { scroll: false }); navigator.replace(navigation.splashUrl, { scroll: false });
}; };
const view = getProfileViewModel({ const view = getSidebarViewModel({
loginStatus: auth.loginStatus, loginStatus: auth.loginStatus,
user, user,
}); });
@@ -59,7 +57,7 @@ export function ProfileScreen({ returnTo }: ProfileScreenProps) {
<BackButton <BackButton
href={navigation.chatUrl} href={navigation.chatUrl}
variant="soft" variant="soft"
analyticsKey="profile.back_to_chat" analyticsKey="sidebar.back_to_chat"
/> />
</div> </div>
@@ -69,12 +67,12 @@ export function ProfileScreen({ returnTo }: ProfileScreenProps) {
name={view.name} name={view.name}
avatarUrl={view.avatarUrl} avatarUrl={view.avatarUrl}
isLoading={view.isInitializingUser} isLoading={view.isInitializingUser}
onLoginClick={() => navigator.openAuth(navigation.profileUrl)} onLoginClick={() => navigator.openAuth(navigation.sidebarUrl)}
/> />
</section> </section>
<section className={`${styles.cardSlot} ${styles.revealTwo}`}> <section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<ProfileWalletCard <SidebarWalletCard
creditBalance={view.wallet.creditBalance} creditBalance={view.wallet.creditBalance}
dailyFreeChatLimit={view.wallet.dailyFreeChatLimit} dailyFreeChatLimit={view.wallet.dailyFreeChatLimit}
dailyFreeChatRemaining={view.wallet.dailyFreeChatRemaining} dailyFreeChatRemaining={view.wallet.dailyFreeChatRemaining}
@@ -86,9 +84,9 @@ export function ProfileScreen({ returnTo }: ProfileScreenProps) {
navigator.openSubscription({ navigator.openSubscription({
type: "vip", type: "vip",
sourceCharacterSlug: navigation.characterSlug, sourceCharacterSlug: navigation.characterSlug,
returnTo: "profile", returnTo: "sidebar",
analytics: { analytics: {
entryPoint: "profile", entryPoint: "sidebar",
triggerReason: "vip_cta", triggerReason: "vip_cta",
}, },
}) })
@@ -98,10 +96,10 @@ export function ProfileScreen({ returnTo }: ProfileScreenProps) {
navigator.openSubscription({ navigator.openSubscription({
type: "topup", type: "topup",
sourceCharacterSlug: navigation.characterSlug, sourceCharacterSlug: navigation.characterSlug,
returnTo: "profile", returnTo: "sidebar",
analytics: { analytics: {
entryPoint: "profile", entryPoint: "sidebar",
triggerReason: "profile_recharge", triggerReason: "sidebar_recharge",
}, },
}) })
} }
@@ -115,7 +113,7 @@ export function ProfileScreen({ returnTo }: ProfileScreenProps) {
<div className={styles.settingsActions}> <div className={styles.settingsActions}>
<button <button
type="button" type="button"
data-analytics-key="profile.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(navigation.feedbackUrl)} onClick={() => navigator.push(navigation.feedbackUrl)}
@@ -157,7 +155,7 @@ export function ProfileScreen({ returnTo }: ProfileScreenProps) {
<button <button
type="button" type="button"
data-analytics-key="profile.logout" data-analytics-key="sidebar.logout"
data-analytics-label="Log out" data-analytics-label="Log out"
className={styles.logoutCard} className={styles.logoutCard}
onClick={handleLogoutClick} onClick={handleLogoutClick}
@@ -1,17 +1,17 @@
import type { LoginStatus } from "@/data/schemas/auth"; import type { LoginStatus } from "@/data/schemas/auth";
import type { UserView } from "@/stores/user/user-view"; import type { UserView } from "@/stores/user/user-view";
export type ProfileUserState = "guest" | "member" | "vip"; export type SidebarUserState = "guest" | "member" | "vip";
export const PROFILE_FALLBACK_USERNAME = "User name"; export const SIDEBAR_FALLBACK_USERNAME = "User name";
export interface ProfileUserInput { export interface SidebarUserInput {
currentUser: UserView | null; currentUser: UserView | null;
avatarUrl: string | null; avatarUrl: string | null;
creditBalance?: number | null; creditBalance?: number | null;
} }
export interface ProfileWalletView { export interface SidebarWalletView {
creditBalance: number; creditBalance: number;
dailyFreeChatLimit: number; dailyFreeChatLimit: number;
dailyFreeChatRemaining: number; dailyFreeChatRemaining: number;
@@ -19,67 +19,65 @@ export interface ProfileWalletView {
dailyFreePrivateRemaining: number; dailyFreePrivateRemaining: number;
} }
export interface ProfileViewModel { export interface SidebarViewModel {
state: ProfileUserState; state: SidebarUserState;
name: string; name: string;
avatarUrl: string | null; avatarUrl: string | null;
isInitializingUser: boolean; isInitializingUser: boolean;
wallet: ProfileWalletView; wallet: SidebarWalletView;
canActivateVip: boolean; canActivateVip: boolean;
canShowSettings: boolean; canShowSettings: boolean;
} }
export function getProfileViewModel({ export function getSidebarViewModel({
loginStatus, loginStatus,
user, user,
}: { }: {
loginStatus: LoginStatus; loginStatus: LoginStatus;
user: ProfileUserInput; user: SidebarUserInput;
}): ProfileViewModel { }): SidebarViewModel {
const state = getProfileUserState(loginStatus, user.currentUser); const state = getSidebarUserState(loginStatus, user.currentUser);
return { return {
state, state,
name: user.currentUser?.username ?? PROFILE_FALLBACK_USERNAME, name: user.currentUser?.username ?? SIDEBAR_FALLBACK_USERNAME,
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
isInitializingUser: state !== "guest" && user.currentUser == null, isInitializingUser: state !== "guest" && user.currentUser == null,
wallet: getProfileWalletView(user), wallet: getSidebarWalletView(user),
canActivateVip: state !== "vip", canActivateVip: state !== "vip",
canShowSettings: state !== "guest", canShowSettings: state !== "guest",
}; };
} }
export function getProfileUserState( export function getSidebarUserState(
loginStatus: LoginStatus, loginStatus: LoginStatus,
currentUser: UserView | null, currentUser: UserView | null,
): ProfileUserState { ): SidebarUserState {
if (loginStatus === "notLoggedIn" || loginStatus === "guest") { if (loginStatus === "notLoggedIn") return "guest";
return "guest";
}
return currentUser?.isVip === true ? "vip" : "member"; return currentUser?.isVip === true ? "vip" : "member";
} }
export function getProfileWalletView( export function getSidebarWalletView(
user: ProfileUserInput, user: SidebarUserInput,
): ProfileWalletView { ): SidebarWalletView {
const currentUser = user.currentUser; const currentUser = user.currentUser;
return { return {
creditBalance: normalizeProfileCount(user.creditBalance), creditBalance: normalizeSidebarCount(user.creditBalance),
dailyFreeChatLimit: normalizeProfileCount(currentUser?.dailyFreeChatLimit), dailyFreeChatLimit: normalizeSidebarCount(currentUser?.dailyFreeChatLimit),
dailyFreeChatRemaining: normalizeProfileCount( dailyFreeChatRemaining: normalizeSidebarCount(
currentUser?.dailyFreeChatRemaining, currentUser?.dailyFreeChatRemaining,
), ),
dailyFreePrivateLimit: normalizeProfileCount( dailyFreePrivateLimit: normalizeSidebarCount(
currentUser?.dailyFreePrivateLimit, currentUser?.dailyFreePrivateLimit,
), ),
dailyFreePrivateRemaining: normalizeProfileCount( dailyFreePrivateRemaining: normalizeSidebarCount(
currentUser?.dailyFreePrivateRemaining, currentUser?.dailyFreePrivateRemaining,
), ),
}; };
} }
export function normalizeProfileCount(value: number | null | undefined): number { export function normalizeSidebarCount(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0; if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value)); return Math.max(0, Math.trunc(value));
} }
@@ -4,17 +4,17 @@ import { type Dispatch, useEffect } from "react";
import type { LoginStatus } from "@/data/schemas/auth"; import type { LoginStatus } from "@/data/schemas/auth";
interface ProfileAuthBootstrapState { interface SidebarAuthBootstrapState {
hasInitialized: boolean; hasInitialized: boolean;
isLoading: boolean; isLoading: boolean;
loginStatus: LoginStatus; loginStatus: LoginStatus;
} }
export function useProfileUserBootstrap({ export function useSidebarUserBootstrap({
auth, auth,
userDispatch, userDispatch,
}: { }: {
auth: ProfileAuthBootstrapState; auth: SidebarAuthBootstrapState;
userDispatch: Dispatch<{ type: "UserFetch" }>; userDispatch: Dispatch<{ type: "UserFetch" }>;
}) { }) {
useEffect(() => { useEffect(() => {
@@ -22,7 +22,7 @@ describe("splash Tailwind components", () => {
); );
expect(html).toContain("absolute"); expect(html).toContain("absolute");
expect(html).toContain("bg-splash-background"); expect(html).toContain("bg-sidebar-background");
expect(html).toContain("object-cover"); expect(html).toContain("object-cover");
expect(html).toContain("%2Fimages%2Fcover%2Fmaya.png"); expect(html).toContain("%2Fimages%2Fcover%2Fmaya.png");
}); });
@@ -16,7 +16,7 @@ export function SplashBackground({
src: string; src: string;
}) { }) {
return ( return (
<div className="absolute inset-0 z-0 overflow-hidden bg-splash-background"> <div className="absolute inset-0 z-0 overflow-hidden bg-sidebar-background">
<Image <Image
src={src} src={src}
alt="" alt=""
+1 -1
View File
@@ -50,7 +50,7 @@ export function SplashScreen() {
}, []); }, []);
return ( return (
<MobileShell background="var(--color-splash-background)"> <MobileShell background="var(--color-sidebar-background)">
<div className={styles.wrapper}> <div className={styles.wrapper}>
<SplashBackground src={character.assets.cover} /> <SplashBackground src={character.assets.cover} />
{/* 渐变叠加层:accent → transparent (bottom-left → center-right) */} {/* 渐变叠加层:accent → transparent (bottom-left → center-right) */}
@@ -19,7 +19,7 @@ describe("SubscriptionPage", () => {
const page = await SubscriptionPage({ const page = await SubscriptionPage({
searchParams: Promise.resolve({ searchParams: Promise.resolve({
character: "maya", character: "maya",
returnTo: "profile", returnTo: "sidebar",
type: "topup", type: "topup",
}), }),
}); });
@@ -27,7 +27,7 @@ describe("SubscriptionPage", () => {
expect(renderToStaticMarkup(page)).toContain("Global subscription"); expect(renderToStaticMarkup(page)).toContain("Global subscription");
expect(mocks.screenProps).toHaveBeenCalledWith( expect(mocks.screenProps).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
returnTo: "profile", returnTo: "sidebar",
sourceCharacterSlug: "maya", sourceCharacterSlug: "maya",
subscriptionType: "topup", subscriptionType: "topup",
}), }),
@@ -355,6 +355,7 @@ function makePaymentState(
planCatalog: "tip", planCatalog: "tip",
plans: [giftPlan], plans: [giftPlan],
giftCategories: [giftCategory], giftCategories: [giftCategory],
giftOffer: null,
giftProducts: [giftProduct], giftProducts: [giftProduct],
selectedGiftCategory: giftCategory.category, selectedGiftCategory: giftCategory.category,
isFirstRecharge: false, isFirstRecharge: false,
+2 -1
View File
@@ -64,6 +64,7 @@ export function TipScreen({
paymentType: "tip", paymentType: "tip",
shouldResumePendingOrder, shouldResumePendingOrder,
}); });
const offerPrompt = payment.giftOffer?.prompt.trim() || supportPrompt.prompt;
const selectedCategory = const selectedCategory =
payment.giftCategories.find( payment.giftCategories.find(
@@ -236,7 +237,7 @@ export function TipScreen({
supportPrompt.isReady ? styles.supportPromptReady : "" supportPrompt.isReady ? styles.supportPromptReady : ""
}`} }`}
> >
{supportPrompt.prompt} {offerPrompt}
</p> </p>
<div className={styles.purchaseFlow}> <div className={styles.purchaseFlow}>
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { ChatSendResponseSchema } from "@/data/schemas/chat";
import { sendResponseToUiMessage } from "@/stores/chat/helper/message-mappers";
describe("chat commercial actions", () => {
it("maps the backend gift invitation onto the assistant UI message", () => {
const response = ChatSendResponseSchema.parse({
reply: "You always know how to make me smile.",
messageId: "message-1",
timestamp: 1_753_092_000_000,
commercialAction: {
actionId: "action-1",
type: "giftOffer",
copy: "A coffee would make this moment even sweeter.",
ctaLabel: "Choose a gift",
target: "giftCatalog",
ruleId: "compliment_30",
},
});
const message = sendResponseToUiMessage(response);
expect(message.commercialAction).toEqual(response.commercialAction);
expect(message.isFromAI).toBe(true);
});
it("keeps ordinary chat responses free of commercial actions", () => {
const response = ChatSendResponseSchema.parse({ reply: "I'm here." });
expect(response.commercialAction).toBeNull();
expect(sendResponseToUiMessage(response)).not.toHaveProperty(
"commercialAction",
);
});
});
@@ -0,0 +1,14 @@
import { z } from "zod";
export const CommercialActionSchema = z
.object({
actionId: z.string().min(1),
type: z.literal("giftOffer"),
copy: z.string().min(1),
ctaLabel: z.string().min(1),
target: z.literal("giftCatalog"),
ruleId: z.string().min(1),
})
.readonly();
export type CommercialAction = z.output<typeof CommercialActionSchema>;
+3
View File
@@ -6,11 +6,14 @@ export * from "./chat_lock_type";
export * from "./chat_media"; export * from "./chat_media";
export * from "./chat_message"; export * from "./chat_message";
export * from "./chat_payloads"; export * from "./chat_payloads";
export * from "./commercial_action";
export * from "./request/send_message_request"; export * from "./request/send_message_request";
export * from "./request/tip_offer_action_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";
export * from "./response/chat_previews_response"; export * from "./response/chat_previews_response";
export * from "./response/chat_send_response"; export * from "./response/chat_send_response";
export * from "./response/tip_offer_action_response";
export * from "./response/unlock_history_response"; export * from "./response/unlock_history_response";
export * from "./response/unlock_private_response"; export * from "./response/unlock_private_response";
@@ -0,0 +1,15 @@
import { z } from "zod";
export const TipOfferActionRequestSchema = z
.object({
characterId: z.string().min(1).max(64),
sessionId: z.string().min(1).max(64),
action: z.enum(["opened", "dismissed"]),
triggerSource: z.enum(["manualMenu", "aiContext"]),
orderId: z.string().min(1).max(160).optional(),
})
.readonly();
export type TipOfferActionRequest = z.output<
typeof TipOfferActionRequestSchema
>;
@@ -8,9 +8,11 @@ import {
booleanOrTrue, booleanOrTrue,
numberOrLazy, numberOrLazy,
numberOrZero, numberOrZero,
schemaOrNull,
stringOrEmpty, stringOrEmpty,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads"; import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
import { CommercialActionSchema } from "../commercial_action";
export const ChatSendResponseSchema = z export const ChatSendResponseSchema = z
.object({ .object({
@@ -27,6 +29,7 @@ export const ChatSendResponseSchema = z
creditsCharged: numberOrZero, creditsCharged: numberOrZero,
requiredCredits: numberOrZero, requiredCredits: numberOrZero,
shortfallCredits: numberOrZero, shortfallCredits: numberOrZero,
commercialAction: schemaOrNull(CommercialActionSchema),
}) })
.readonly(); .readonly();
@@ -0,0 +1,25 @@
import { z } from "zod";
import { stringOrEmpty, stringOrNull } from "../../nullable-defaults";
export const TipOfferActionResponseSchema = z
.object({
interactionId: stringOrNull,
sessionId: z.string().min(1),
characterId: z.string().min(1),
action: z.enum(["opened", "dismissed"]),
message: z
.object({
messageId: stringOrEmpty,
role: z.literal("assistant"),
messageType: z.literal("tipOffer"),
content: z.string(),
createdAt: stringOrNull,
})
.readonly(),
})
.readonly();
export type TipOfferActionResponse = z.output<
typeof TipOfferActionResponseSchema
>;
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
import {
booleanOrTrue,
numberOrZero,
stringOrEmpty,
} from "../nullable-defaults";
export const GiftOfferSchema = z
.object({
characterId: stringOrEmpty,
enabled: booleanOrTrue,
prompt: stringOrEmpty,
declinedMessage: stringOrEmpty,
displayMode: z.literal("manual").default("manual"),
cooldownSeconds: numberOrZero,
})
.readonly();
export type GiftOffer = z.output<typeof GiftOfferSchema>;
+1
View File
@@ -4,6 +4,7 @@
export * from "./payment_plan"; export * from "./payment_plan";
export * from "./gift_category"; export * from "./gift_category";
export * from "./gift_offer";
export * from "./gift_product"; export * from "./gift_product";
export * from "./request/create_payment_order_request"; export * from "./request/create_payment_order_request";
export * from "./request/tip_message_request"; export * from "./request/tip_message_request";
@@ -1,12 +1,18 @@
import { z } from "zod"; import { z } from "zod";
import { arrayOrEmpty, stringOrNull } from "../../nullable-defaults"; import {
arrayOrEmpty,
schemaOrNull,
stringOrNull,
} from "../../nullable-defaults";
import { GiftCategorySchema } from "../gift_category"; import { GiftCategorySchema } from "../gift_category";
import { GiftOfferSchema } from "../gift_offer";
import { GiftProductSchema } from "../gift_product"; import { GiftProductSchema } from "../gift_product";
export const GiftProductsResponseSchema = z export const GiftProductsResponseSchema = z
.object({ .object({
characterId: stringOrNull, characterId: stringOrNull,
offer: schemaOrNull(GiftOfferSchema),
categories: arrayOrEmpty(GiftCategorySchema), categories: arrayOrEmpty(GiftCategorySchema),
plans: arrayOrEmpty(GiftProductSchema), plans: arrayOrEmpty(GiftProductSchema),
}) })
@@ -1,5 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { stringOrEmpty, stringOrNull } from "../../nullable-defaults";
export const TipMessageResponseSchema = z export const TipMessageResponseSchema = z
.object({ .object({
orderId: z.string().min(1), orderId: z.string().min(1),
@@ -9,6 +11,8 @@ export const TipMessageResponseSchema = z
tipCount: z.number().int().positive(), tipCount: z.number().int().positive(),
poolIndex: z.number().int().min(0).max(99), poolIndex: z.number().int().min(0).max(99),
message: z.string().min(1), message: z.string().min(1),
messageId: stringOrEmpty,
createdAt: stringOrNull,
}) })
.readonly(); .readonly();
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
RelationshipDiariesResponseSchema,
RelationshipDiaryUnlockResponseSchema,
} from "@/data/schemas/private-room";
describe("relationship diary contracts", () => {
it("keeps locked diary payloads free of content and media URLs", () => {
const response = RelationshipDiariesResponseSchema.parse({
characterId: "maya",
unreadCount: 1,
creditBalance: 90,
currency: "credits",
nextCursor: null,
items: [
{
diaryId: "diary-1",
characterId: "maya",
diaryDate: "2026-07-21",
language: "en",
title: "A note from today",
teaserText: "I kept thinking about our conversation.",
unlockCostCredits: 120,
locked: true,
seen: false,
publishedAt: "2026-07-21T11:00:00Z",
},
],
});
expect(response.items[0]).not.toHaveProperty("content");
expect(response.items[0]).not.toHaveProperty("imageUrl");
});
it("parses an atomic successful unlock with signed media", () => {
const response = RelationshipDiaryUnlockResponseSchema.parse({
reason: "ok",
unlocked: true,
creditsCharged: 120,
previousCreditBalance: 200,
creditBalance: 80,
diary: {
diaryId: "diary-1",
characterId: "maya",
diaryDate: "2026-07-21",
language: "en",
title: "A note from today",
teaserText: "I kept thinking about our conversation.",
unlockCostCredits: 120,
locked: false,
seen: true,
publishedAt: "2026-07-21T11:00:00Z",
content: "This is the private diary body.",
imageUrl:
"https://demo.supabase.co/storage/v1/object/sign/relationship-diaries/maya/1.jpg?token=test",
},
});
expect(response.reason).toBe("ok");
expect(response.diary?.locked).toBe(false);
expect(response.diary?.imageUrl).toContain("/object/sign/");
});
});
+5
View File
@@ -3,6 +3,11 @@
*/ */
export * from "./private_album"; export * from "./private_album";
export * from "./relationship_diary";
export * from "./request/unlock_private_album_request"; export * from "./request/unlock_private_album_request";
export * from "./request/unlock_relationship_diary_request";
export * from "./response/private_album_unlock_response"; export * from "./response/private_album_unlock_response";
export * from "./response/private_albums_response"; export * from "./response/private_albums_response";
export * from "./response/relationship_diaries_response";
export * from "./response/relationship_diary_seen_response";
export * from "./response/relationship_diary_unlock_response";
@@ -0,0 +1,27 @@
import { z } from "zod";
import {
booleanOrFalse,
numberOrZero,
stringOrEmpty,
stringOrNull,
} from "../nullable-defaults";
export const RelationshipDiarySchema = z
.object({
diaryId: z.string().min(1),
characterId: z.string().min(1),
diaryDate: stringOrNull,
language: stringOrEmpty,
title: stringOrEmpty,
teaserText: stringOrEmpty,
unlockCostCredits: numberOrZero,
locked: booleanOrFalse,
seen: booleanOrFalse,
publishedAt: stringOrNull,
content: z.string().optional(),
imageUrl: z.string().url().optional(),
})
.readonly();
export type RelationshipDiary = z.output<typeof RelationshipDiarySchema>;
@@ -0,0 +1,11 @@
import { z } from "zod";
export const UnlockRelationshipDiaryRequestSchema = z
.object({
expectedCost: z.number().int().positive(),
})
.readonly();
export type UnlockRelationshipDiaryRequest = z.output<
typeof UnlockRelationshipDiaryRequestSchema
>;
@@ -0,0 +1,24 @@
import { z } from "zod";
import {
arrayOrEmpty,
numberOrZero,
stringOrEmpty,
stringOrNull,
} from "../../nullable-defaults";
import { RelationshipDiarySchema } from "../relationship_diary";
export const RelationshipDiariesResponseSchema = z
.object({
characterId: stringOrEmpty,
unreadCount: numberOrZero,
creditBalance: numberOrZero,
currency: z.literal("credits").default("credits"),
items: arrayOrEmpty(RelationshipDiarySchema),
nextCursor: stringOrNull,
})
.readonly();
export type RelationshipDiariesResponse = z.output<
typeof RelationshipDiariesResponseSchema
>;
@@ -0,0 +1,15 @@
import { z } from "zod";
import { booleanOrFalse, stringOrNull } from "../../nullable-defaults";
export const RelationshipDiarySeenResponseSchema = z
.object({
reason: z.enum(["ok", "not_found"]),
seen: booleanOrFalse,
seenAt: stringOrNull,
})
.readonly();
export type RelationshipDiarySeenResponse = z.output<
typeof RelationshipDiarySeenResponseSchema
>;
@@ -0,0 +1,35 @@
import { z } from "zod";
import {
booleanOrFalse,
numberOrZero,
schemaOrNull,
} from "../../nullable-defaults";
import { RelationshipDiarySchema } from "../relationship_diary";
export const RelationshipDiaryUnlockReasonSchema = z.enum([
"ok",
"already_unlocked",
"insufficient_credits",
"cost_changed",
"not_found",
"deduct_failed",
]);
export const RelationshipDiaryUnlockResponseSchema = z
.object({
reason: RelationshipDiaryUnlockReasonSchema,
unlocked: booleanOrFalse,
creditsCharged: numberOrZero,
creditBalance: numberOrZero,
requiredCredits: numberOrZero,
currentCredits: numberOrZero,
shortfallCredits: numberOrZero,
previousCreditBalance: numberOrZero,
diary: schemaOrNull(RelationshipDiarySchema),
})
.readonly();
export type RelationshipDiaryUnlockResponse = z.output<
typeof RelationshipDiaryUnlockResponseSchema
>;
@@ -5,8 +5,13 @@ import { ApiPath } from "../api_path";
describe("ApiPath contract source", () => { describe("ApiPath contract source", () => {
it("uses the shared contract path for every static operation", () => { it("uses the shared contract path for every static operation", () => {
const dynamicOperations = new Set([
"privateRoomAlbumUnlock",
"privateRoomDiarySeen",
"privateRoomDiaryUnlock",
]);
const staticOperations = Object.entries(apiContract).filter( const staticOperations = Object.entries(apiContract).filter(
([operationId]) => operationId !== "privateRoomAlbumUnlock", ([operationId]) => !dynamicOperations.has(operationId),
); );
for (const [operationId, operation] of staticOperations) { for (const [operationId, operation] of staticOperations) {
@@ -21,4 +26,13 @@ describe("ApiPath contract source", () => {
"/api/private-room/albums/album%2Fid%201/unlock", "/api/private-room/albums/album%2Fid%201/unlock",
); );
}); });
it("fills and encodes relationship diary path parameters", () => {
expect(ApiPath.privateRoomDiarySeen("diary/id 1")).toBe(
"/api/private-room/diaries/diary%2Fid%201/seen",
);
expect(ApiPath.privateRoomDiaryUnlock("diary/id 1")).toBe(
"/api/private-room/diaries/diary%2Fid%201/unlock",
);
});
}); });
+4
View File
@@ -18,11 +18,15 @@
"paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" }, "paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" },
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" }, "paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
"chatSend": { "method": "post", "path": "/api/chat/send" }, "chatSend": { "method": "post", "path": "/api/chat/send" },
"chatTipOfferActions": { "method": "post", "path": "/api/chat/tip-offer-actions" },
"chatHistory": { "method": "get", "path": "/api/chat/history" }, "chatHistory": { "method": "get", "path": "/api/chat/history" },
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" }, "chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
"chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" }, "chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" },
"privateRoomAlbums": { "method": "get", "path": "/api/private-room/albums" }, "privateRoomAlbums": { "method": "get", "path": "/api/private-room/albums" },
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" }, "privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
"privateRoomDiaries": { "method": "get", "path": "/api/private-room/diaries" },
"privateRoomDiarySeen": { "method": "post", "path": "/api/private-room/diaries/{diaryId}/seen" },
"privateRoomDiaryUnlock": { "method": "post", "path": "/api/private-room/diaries/{diaryId}/unlock" },
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" }, "metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
"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" },
+22
View File
@@ -70,6 +70,9 @@ export class ApiPath {
/** 发送消息 */ /** 发送消息 */
static readonly chatSend = apiContract.chatSend.path; static readonly chatSend = apiContract.chatSend.path;
/** 记录礼物邀请打开或取消动作 */
static readonly chatTipOfferActions = apiContract.chatTipOfferActions.path;
/** 获取聊天历史 */ /** 获取聊天历史 */
static readonly chatHistory = apiContract.chatHistory.path; static readonly chatHistory = apiContract.chatHistory.path;
@@ -91,6 +94,25 @@ export class ApiPath {
); );
} }
/** 获取当前角色的专属日记 */
static readonly privateRoomDiaries = apiContract.privateRoomDiaries.path;
/** 将专属日记标记为已读 */
static privateRoomDiarySeen(diaryId: string): string {
return apiContract.privateRoomDiarySeen.path.replace(
"{diaryId}",
encodeURIComponent(diaryId),
);
}
/** 使用积分解锁专属日记 */
static privateRoomDiaryUnlock(diaryId: string): string {
return apiContract.privateRoomDiaryUnlock.path.replace(
"{diaryId}",
encodeURIComponent(diaryId),
);
}
// ============ 数据看板相关 ============ // ============ 数据看板相关 ============
/** 上报 PWA 事件 */ /** 上报 PWA 事件 */
static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path; static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path;
+17
View File
@@ -12,6 +12,10 @@ import {
ChatSendResponse, ChatSendResponse,
ChatSendResponseSchema, ChatSendResponseSchema,
SendMessageRequest, SendMessageRequest,
TipOfferActionRequest,
TipOfferActionRequestSchema,
TipOfferActionResponse,
TipOfferActionResponseSchema,
UnlockHistoryRequest, UnlockHistoryRequest,
UnlockHistoryResponse, UnlockHistoryResponse,
UnlockHistoryResponseSchema, UnlockHistoryResponseSchema,
@@ -39,6 +43,19 @@ export class ChatApi {
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>); return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
} }
async recordTipOfferAction(
body: TipOfferActionRequest,
): Promise<TipOfferActionResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatTipOfferActions,
{
method: "POST",
body: TipOfferActionRequestSchema.parse(body),
},
);
return TipOfferActionResponseSchema.parse(unwrap(env));
}
/** /**
* *
*/ */
+53
View File
@@ -4,6 +4,13 @@ import {
PrivateAlbumUnlockResponse, PrivateAlbumUnlockResponse,
PrivateAlbumUnlockResponseSchema, PrivateAlbumUnlockResponseSchema,
UnlockPrivateAlbumRequest, UnlockPrivateAlbumRequest,
RelationshipDiariesResponse,
RelationshipDiariesResponseSchema,
RelationshipDiarySeenResponse,
RelationshipDiarySeenResponseSchema,
RelationshipDiaryUnlockResponse,
RelationshipDiaryUnlockResponseSchema,
UnlockRelationshipDiaryRequest,
} from "@/data/schemas/private-room"; } from "@/data/schemas/private-room";
import { ApiPath } from "./api_path"; import { ApiPath } from "./api_path";
@@ -15,6 +22,12 @@ export interface GetPrivateAlbumsInput {
limit?: number; limit?: number;
} }
export interface GetRelationshipDiariesInput {
characterId: string;
limit?: number;
cursor?: string | null;
}
export class PrivateRoomApi { export class PrivateRoomApi {
async getAlbums( async getAlbums(
input: GetPrivateAlbumsInput, input: GetPrivateAlbumsInput,
@@ -44,6 +57,46 @@ export class PrivateRoomApi {
); );
return PrivateAlbumUnlockResponseSchema.parse(unwrap(env)); return PrivateAlbumUnlockResponseSchema.parse(unwrap(env));
} }
async getDiaries(
input: GetRelationshipDiariesInput,
): Promise<RelationshipDiariesResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.privateRoomDiaries,
{
query: {
characterId: input.characterId,
limit: input.limit ?? 20,
...(input.cursor ? { cursor: input.cursor } : {}),
},
},
);
return RelationshipDiariesResponseSchema.parse(unwrap(env));
}
async markDiarySeen(
diaryId: string,
): Promise<RelationshipDiarySeenResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.privateRoomDiarySeen(diaryId),
{ method: "POST" },
);
return RelationshipDiarySeenResponseSchema.parse(unwrap(env));
}
async unlockDiary(
diaryId: string,
body: UnlockRelationshipDiaryRequest,
): Promise<RelationshipDiaryUnlockResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.privateRoomDiaryUnlock(diaryId),
{
method: "POST",
body,
},
);
return RelationshipDiaryUnlockResponseSchema.parse(unwrap(env));
}
} }
export const privateRoomApi = new PrivateRoomApi(); export const privateRoomApi = new PrivateRoomApi();
@@ -5,7 +5,10 @@ import memoryDriver from "unstorage/drivers/memory";
import { StorageKeys } from "@/data/storage/storage_keys"; import { StorageKeys } from "@/data/storage/storage_keys";
import { SessionAsyncUtil } from "@/utils/session-storage"; import { SessionAsyncUtil } from "@/utils/session-storage";
import { NavigationStorage } from "../navigation_storage"; import {
createPendingChatPromotion,
NavigationStorage,
} from "../navigation_storage";
const CHARACTER_ID = "elio"; const CHARACTER_ID = "elio";
const OTHER_CHARACTER_ID = "maya-tan"; const OTHER_CHARACTER_ID = "maya-tan";
@@ -105,6 +108,28 @@ describe("NavigationStorage", () => {
}); });
}); });
it("creates manual promotions without persisting them", async () => {
const image = createPendingChatPromotion("image", CHARACTER_ID);
const voice = createPendingChatPromotion("voice", CHARACTER_ID);
expect(image).toMatchObject({
characterId: CHARACTER_ID,
promotionType: "image",
lockType: "image_paywall",
});
expect(voice).toMatchObject({
characterId: CHARACTER_ID,
promotionType: "voice",
lockType: "voice_message",
});
expect(image.clientLockId).toMatch(/^promotion_/);
expect(voice.clientLockId).toMatch(/^promotion_/);
expect(voice.clientLockId).not.toBe(image.clientLockId);
await expect(
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
).resolves.toBeNull();
});
it("saves and consumes pending chat image return sessions", async () => { it("saves and consumes pending chat image return sessions", async () => {
await NavigationStorage.savePendingChatImageReturn({ await NavigationStorage.savePendingChatImageReturn({
characterId: CHARACTER_ID, characterId: CHARACTER_ID,
@@ -69,6 +69,19 @@ export type PendingChatImageReturn = z.output<
typeof PendingChatImageReturnSchema typeof PendingChatImageReturnSchema
>; >;
export function createPendingChatPromotion(
promotionType: PendingChatPromotionType,
characterId: string,
): PendingChatPromotion {
return PendingChatPromotionSchema.parse({
characterId,
promotionType,
lockType: toPromotionLockType(promotionType),
clientLockId: `promotion_${createUuidV4()}`,
createdAt: Date.now(),
});
}
/** /**
* NavigationStorage owns short-lived cross-route sessions. * NavigationStorage owns short-lived cross-route sessions.
* *
@@ -180,13 +193,10 @@ export class NavigationStorage {
promotionType: PendingChatPromotionType, promotionType: PendingChatPromotionType,
characterId: string, characterId: string,
): Promise<PendingChatPromotion> { ): Promise<PendingChatPromotion> {
const promotion: PendingChatPromotion = { const promotion = createPendingChatPromotion(
characterId,
promotionType, promotionType,
lockType: toPromotionLockType(promotionType), characterId,
clientLockId: `promotion_${createUuidV4()}`, );
createdAt: Date.now(),
};
await SessionAsyncUtil.setJson( await SessionAsyncUtil.setJson(
StorageKeys.pendingChatPromotion, StorageKeys.pendingChatPromotion,
promotion, promotion,
@@ -14,7 +14,7 @@ const PendingPaymentOrderSchema = z
subscriptionType: z.enum(["vip", "topup", "tip"]), subscriptionType: z.enum(["vip", "topup", "tip"]),
giftCategory: z.string().min(1).nullable().default(null), giftCategory: z.string().min(1).nullable().default(null),
giftPlanId: z.string().min(1).nullable().default(null), giftPlanId: z.string().min(1).nullable().default(null),
returnTo: z.enum(["chat", "private-room", "profile"]).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(),
}) })
@@ -30,10 +30,10 @@ describe("payment analytics context", () => {
expect( expect(
parsePaymentAnalyticsContext({ parsePaymentAnalyticsContext({
entryPoint: "profile", entryPoint: "sidebar",
triggerReason: null, triggerReason: null,
subscriptionType: "vip", subscriptionType: "vip",
}), }),
).toEqual({ entryPoint: "profile", triggerReason: "vip_cta" }); ).toEqual({ entryPoint: "sidebar", triggerReason: "vip_cta" });
}); });
}); });
@@ -5,7 +5,7 @@ export type PaymentAnalyticsTriggerReason =
| "daily_chat_limit" | "daily_chat_limit"
| "private_topic_limit" | "private_topic_limit"
| "insufficient_credits" | "insufficient_credits"
| "profile_recharge" | "sidebar_recharge"
| "vip_cta" | "vip_cta"
| "ad_landing" | "ad_landing"
| "manual_recharge" | "manual_recharge"
@@ -16,7 +16,7 @@ export type PaymentAnalyticsEntryPoint =
| "chat_unlock" | "chat_unlock"
| "private_album_unlock" | "private_album_unlock"
| "private_room" | "private_room"
| "profile" | "sidebar"
| "chat_offer_banner" | "chat_offer_banner"
| "subscription_direct" | "subscription_direct"
| "tip_page" | "tip_page"
@@ -33,7 +33,7 @@ const ENTRY_POINTS = new Set<PaymentAnalyticsEntryPoint>([
"chat_unlock", "chat_unlock",
"private_album_unlock", "private_album_unlock",
"private_room", "private_room",
"profile", "sidebar",
"chat_offer_banner", "chat_offer_banner",
"subscription_direct", "subscription_direct",
"tip_page", "tip_page",
@@ -45,7 +45,7 @@ const TRIGGER_REASONS = new Set<PaymentAnalyticsTriggerReason>([
"daily_chat_limit", "daily_chat_limit",
"private_topic_limit", "private_topic_limit",
"insufficient_credits", "insufficient_credits",
"profile_recharge", "sidebar_recharge",
"vip_cta", "vip_cta",
"ad_landing", "ad_landing",
"manual_recharge", "manual_recharge",
+39
View File
@@ -0,0 +1,39 @@
import type { TipOfferActionRequest } from "@/data/schemas/chat";
import { chatApi } from "@/data/services/api/chat_api";
const SESSION_KEY_PREFIX = "cozsweet.tipOffer.session";
export function getTipOfferSessionId(characterId: string): string {
const key = `${SESSION_KEY_PREFIX}.${characterId}`;
try {
const existing = globalThis.sessionStorage.getItem(key);
if (existing) return existing;
const created = createSessionId();
globalThis.sessionStorage.setItem(key, created);
return created;
} catch {
return createSessionId();
}
}
export function recordTipOfferAction(
characterId: string,
action: TipOfferActionRequest["action"],
triggerSource: TipOfferActionRequest["triggerSource"],
): void {
void chatApi
.recordTipOfferAction({
characterId,
sessionId: getTipOfferSessionId(characterId),
action,
triggerSource,
})
.catch(() => undefined);
}
function createSessionId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
}
@@ -29,7 +29,7 @@ describe("external entry navigation", () => {
ROUTES.chat, ROUTES.chat,
); );
expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat); expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat);
expect(resolveExternalEntryTarget({ target: "profile" })).toBe(ROUTES.chat); expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat);
}); });
it("routes every tip entry to the tier selection page", () => { it("routes every tip entry to the tier selection page", () => {
@@ -60,30 +60,30 @@ describe("subscription exit helpers", () => {
); );
}); });
it("falls back to profile by default", async () => { it("falls back to sidebar by default", async () => {
consumePendingChatImageReturnMock.mockResolvedValue(null); consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue(null); peekPendingChatUnlockMock.mockResolvedValue(null);
expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.profile); expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
await expect(consumeSubscriptionExitUrl(null)).resolves.toBe( await expect(consumeSubscriptionExitUrl(null)).resolves.toBe(
ROUTES.profile, ROUTES.sidebar,
); );
}); });
it("returns to global profile with the source character", async () => { it("returns to global sidebar with the source character", async () => {
const expectedUrl = buildGlobalPageUrl( const expectedUrl = buildGlobalPageUrl(
ROUTES.profile, ROUTES.sidebar,
getCharacterRoutes("maya").chat, getCharacterRoutes("maya").chat,
); );
expect(getSubscriptionFallbackExitUrl("profile", "maya")).toBe( expect(getSubscriptionFallbackExitUrl("sidebar", "maya")).toBe(
expectedUrl, expectedUrl,
); );
await expect( await expect(
consumeSubscriptionExitUrl("profile", "maya"), consumeSubscriptionExitUrl("sidebar", "maya"),
).resolves.toBe(expectedUrl); ).resolves.toBe(expectedUrl);
await expect( await expect(
resolveSubscriptionSuccessExitUrl("profile", "maya"), resolveSubscriptionSuccessExitUrl("sidebar", "maya"),
).resolves.toBe(expectedUrl); ).resolves.toBe(expectedUrl);
expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled(); expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled();
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled(); expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
@@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
createPendingChatPromotion as createPromotion,
NavigationStorage, NavigationStorage,
type PendingChatUnlock, type PendingChatUnlock,
type PendingChatUnlockKind, type PendingChatUnlockKind,
@@ -17,6 +18,13 @@ export type {
PendingChatUnlockStage, PendingChatUnlockStage,
}; };
export function createPendingChatPromotion(
promotionType: PendingChatPromotionType,
characterId: string,
): PendingChatPromotion {
return createPromotion(promotionType, characterId);
}
export async function savePendingChatUnlock(input: { export async function savePendingChatUnlock(input: {
characterId: string; characterId: string;
displayMessageId?: string; displayMessageId?: string;
+5 -5
View File
@@ -48,10 +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;
if (returnTo === "profile") { if (returnTo === "sidebar") {
return buildGlobalPageUrl(ROUTES.profile, routes.chat); return buildGlobalPageUrl(ROUTES.sidebar, routes.chat);
} }
return ROUTES.profile; return ROUTES.sidebar;
} }
export async function consumeSubscriptionExitUrl( export async function consumeSubscriptionExitUrl(
@@ -61,7 +61,7 @@ export async function consumeSubscriptionExitUrl(
if (returnTo === "private-room") { if (returnTo === "private-room") {
return getCharacterRoutes(characterSlug).privateRoom; return getCharacterRoutes(characterSlug).privateRoom;
} }
if (returnTo === "profile") { if (returnTo === "sidebar") {
return getSubscriptionFallbackExitUrl(returnTo, characterSlug); return getSubscriptionFallbackExitUrl(returnTo, characterSlug);
} }
@@ -78,7 +78,7 @@ export async function resolveSubscriptionSuccessExitUrl(
if (returnTo === "private-room") { if (returnTo === "private-room") {
return getCharacterRoutes(characterSlug).privateRoom; return getCharacterRoutes(characterSlug).privateRoom;
} }
if (returnTo === "profile") { if (returnTo === "sidebar") {
return getSubscriptionFallbackExitUrl(returnTo, characterSlug); return getSubscriptionFallbackExitUrl(returnTo, characterSlug);
} }
@@ -40,7 +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("profile")).toBe("profile"); expect(parseSubscriptionReturnTo("sidebar")).toBe("sidebar");
expect(parseSubscriptionReturnTo(["private-room", "chat"])).toBe( expect(parseSubscriptionReturnTo(["private-room", "chat"])).toBe(
"private-room", "private-room",
); );
@@ -54,12 +54,12 @@ describe("pending payment order helpers", () => {
await clearPendingPaymentOrder(); await clearPendingPaymentOrder();
}); });
it("stores and rebuilds global profile returns", async () => { it("stores and rebuilds global sidebar returns", async () => {
await clearPendingPaymentOrder(); await clearPendingPaymentOrder();
const saveResult = await savePendingEzpayOrder({ const saveResult = await savePendingEzpayOrder({
orderId: "order-profile", orderId: "order-sidebar",
returnTo: "profile", returnTo: "sidebar",
subscriptionType: "topup", subscriptionType: "topup",
characterSlug: "maya", characterSlug: "maya",
createdAt: 1, createdAt: 1,
@@ -70,20 +70,20 @@ describe("pending payment order helpers", () => {
expect(storedResult).toMatchObject({ expect(storedResult).toMatchObject({
success: true, success: true,
data: { data: {
orderId: "order-profile", orderId: "order-sidebar",
returnTo: "profile", returnTo: "sidebar",
characterSlug: "maya", characterSlug: "maya",
}, },
}); });
expect( expect(
buildPendingPaymentSubscriptionUrl({ buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay", payChannel: "ezpay",
returnTo: "profile", returnTo: "sidebar",
subscriptionType: "topup", subscriptionType: "topup",
characterSlug: "maya", characterSlug: "maya",
}), }),
).toBe( ).toBe(
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=profile&character=maya", "/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=sidebar&character=maya",
); );
await clearPendingPaymentOrder(); await clearPendingPaymentOrder();
}); });
+1 -1
View File
@@ -29,7 +29,7 @@ export function parseSubscriptionReturnTo(
if ( if (
returnTo === "chat" || returnTo === "chat" ||
returnTo === "private-room" || returnTo === "private-room" ||
returnTo === "profile" returnTo === "sidebar"
) { ) {
return returnTo; return returnTo;
} }
@@ -16,8 +16,8 @@ describe("global route context", () => {
chatUrl: "/characters/maya/chat", chatUrl: "/characters/maya/chat",
splashUrl: "/characters/maya/splash", splashUrl: "/characters/maya/splash",
}); });
expect(context.profileUrl).toBe( expect(context.sidebarUrl).toBe(
"/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat", "/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat",
); );
expect(context.feedbackUrl).toBe( expect(context.feedbackUrl).toBe(
"/feedback?returnTo=%2Fcharacters%2Fmaya%2Fchat", "/feedback?returnTo=%2Fcharacters%2Fmaya%2Fchat",
@@ -52,8 +52,8 @@ describe("global route context", () => {
}); });
it("sanitizes return targets when building a global page url", () => { it("sanitizes return targets when building a global page url", () => {
expect(buildGlobalPageUrl(ROUTES.profile, "https://example.com")).toBe( expect(buildGlobalPageUrl(ROUTES.sidebar, "https://example.com")).toBe(
"/profile?returnTo=%2Fcharacters%2Felio%2Fchat", "/sidebar?returnTo=%2Fcharacters%2Felio%2Fchat",
); );
}); });
}); });
@@ -5,6 +5,11 @@ import { LEGACY_GLOBAL_ROUTE_REDIRECTS } from "../legacy-global-route-redirects"
describe("legacy global route redirects", () => { describe("legacy global route redirects", () => {
it("redirects character-scoped global pages without permanent caching", () => { it("redirects character-scoped global pages without permanent caching", () => {
expect(LEGACY_GLOBAL_ROUTE_REDIRECTS).toEqual([ expect(LEGACY_GLOBAL_ROUTE_REDIRECTS).toEqual([
{
source: "/characters/:characterSlug/sidebar",
destination: "/sidebar?returnTo=/characters/:characterSlug/chat",
permanent: false,
},
{ {
source: "/characters/:characterSlug/feedback", source: "/characters/:characterSlug/feedback",
destination: "/feedback?returnTo=/characters/:characterSlug/chat", destination: "/feedback?returnTo=/characters/:characterSlug/chat",
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import {
consumePendingLogoutNavigation,
setPendingLogoutNavigation,
} from "../logout-navigation";
describe("logout navigation", () => {
it("consumes the pending destination only once", () => {
setPendingLogoutNavigation("/characters/maya/splash");
expect(consumePendingLogoutNavigation()).toBe(
"/characters/maya/splash",
);
expect(consumePendingLogoutNavigation()).toBeNull();
});
it("keeps only the latest pending destination", () => {
setPendingLogoutNavigation("/characters/elio/splash");
setPendingLogoutNavigation("/characters/nayeli/splash");
expect(consumePendingLogoutNavigation()).toBe(
"/characters/nayeli/splash",
);
});
});

Some files were not shown because too many files have changed in this diff Show More