Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9fb9f4fd | |||
| dd7d7d5dcf |
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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(?:\\?.*)?$`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-5
@@ -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
@@ -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
@@ -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'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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
export type AvatarInteractionProps =
|
|
||||||
| {
|
|
||||||
onClick?: undefined;
|
|
||||||
actionLabel?: never;
|
|
||||||
analyticsKey?: never;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
onClick: () => void;
|
|
||||||
actionLabel: string;
|
|
||||||
analyticsKey?: string;
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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"',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
/**
|
/**
|
||||||
* ChatHeader 顶部栏
|
* ChatHeader 顶部栏
|
||||||
*
|
*
|
||||||
* 图标:lucide-react <Lock />(游客 banner)+ <UserRound />(Profile)
|
* 图标:lucide-react <Lock />(游客 banner)+ <Menu />(菜单按钮)
|
||||||
* tree-shakable,currentColor 继承父 color
|
* tree-shakable,currentColor 继承父 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+7
-7
@@ -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",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
+16
-16
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+1
-1
@@ -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";
|
||||||
+7
-7
@@ -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}
|
||||||
+9
-9
@@ -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));
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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=""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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
|
||||||
|
>;
|
||||||
@@ -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>;
|
||||||
@@ -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/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聊天历史
|
* 获取聊天历史
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user