diff --git a/docs/backend/FRONTEND_PAYMENT_API.md b/docs/backend/FRONTEND_PAYMENT_API.md index 7dd2ccd9..aef9f094 100644 --- a/docs/backend/FRONTEND_PAYMENT_API.md +++ b/docs/backend/FRONTEND_PAYMENT_API.md @@ -224,7 +224,7 @@ payChannel = ezpay subscriptionType = vip | topup | tip giftCategory(仅 Tip,可空) giftPlanId(仅 Tip,可空) -returnTo = chat | private-room | sidebar(可选) +returnTo = chat | private-room | profile(可选) characterSlug(可选) createdAt ``` diff --git a/e2e/fixtures/helpers/auth.ts b/e2e/fixtures/helpers/auth.ts index a37c55e8..04e2dd58 100644 --- a/e2e/fixtures/helpers/auth.ts +++ b/e2e/fixtures/helpers/auth.ts @@ -11,7 +11,7 @@ export const splashStartChatButtonName = "Start Chatting"; export const defaultCharacterSlug = "elio"; export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`; export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`; -export const defaultCharacterSidebarPath = `/sidebar?returnTo=%2Fcharacters%2F${defaultCharacterSlug}%2Fchat`; +export const defaultCharacterProfilePath = `/profile?returnTo=%2Fcharacters%2F${defaultCharacterSlug}%2Fchat`; export const defaultCharacterChatUrl = new RegExp( `/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`, ); diff --git a/e2e/fixtures/helpers/chat.ts b/e2e/fixtures/helpers/chat.ts index 330cb827..9bada90f 100644 --- a/e2e/fixtures/helpers/chat.ts +++ b/e2e/fixtures/helpers/chat.ts @@ -27,7 +27,7 @@ export async function enterChatFromSplash(page: Page, options: { expectedUrl?: R await expect(startChatButton).toBeVisible({ timeout }); await expect(startChatButton).toBeEnabled(); for (let attempt = 0; attempt < 3; attempt += 1) { - await startChatButton.click(); + await startChatButton.click({ timeout: 3_000 }).catch(() => {}); try { await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); return; } catch { await page.waitForTimeout(500); } } await expect(page).toHaveURL(expectedUrl, { timeout }); diff --git a/e2e/specs/mock/auth/email-login-from-other-options.spec.ts b/e2e/specs/mock/auth/email-login-from-other-options.spec.ts index 99511ee6..e55239ab 100644 --- a/e2e/specs/mock/auth/email-login-from-other-options.spec.ts +++ b/e2e/specs/mock/auth/email-login-from-other-options.spec.ts @@ -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); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Profile" })).toBeVisible(); }); diff --git a/e2e/specs/mock/auth/logout-from-sidebar.spec.ts b/e2e/specs/mock/auth/logout-from-profile.spec.ts similarity index 80% rename from e2e/specs/mock/auth/logout-from-sidebar.spec.ts rename to e2e/specs/mock/auth/logout-from-profile.spec.ts index 27bb0484..215fb868 100644 --- a/e2e/specs/mock/auth/logout-from-sidebar.spec.ts +++ b/e2e/specs/mock/auth/logout-from-profile.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { clearBrowserState, - defaultCharacterSidebarPath, + defaultCharacterProfilePath, defaultCharacterSplashPath, defaultCharacterChatUrl, enterChatFromSplash, @@ -17,7 +17,7 @@ test.beforeEach(async ({ baseURL, context, page }) => { await mockCoreApis(page); }); -test("user can log out from the sidebar after email login", async ({ +test("user can log out from the profile after email login", async ({ page, }) => { await enterChatFromSplash(page); @@ -27,11 +27,11 @@ test("user can log out from the sidebar after email login", async ({ await expect(page).toHaveURL(/\/auth(?:\?.*)?$/); await switchToEmailSignIn(page); await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl }); - await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Profile" })).toBeVisible(); - await page.getByRole("button", { name: "Menu" }).click(); + await page.getByRole("button", { name: "Profile" }).click(); await expect(page).toHaveURL( - new RegExp(defaultCharacterSidebarPath.replace("?", "\\?") + "$"), + new RegExp(defaultCharacterProfilePath.replace("?", "\\?") + "$"), ); const logoutRequestPromise = page.waitForRequest("**/api/auth/logout"); diff --git a/e2e/specs/mock/chat/chat-avatar-navigation.spec.ts b/e2e/specs/mock/chat/chat-avatar-navigation.spec.ts new file mode 100644 index 00000000..b29e5adb --- /dev/null +++ b/e2e/specs/mock/chat/chat-avatar-navigation.spec.ts @@ -0,0 +1,86 @@ +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, + }); +} diff --git a/e2e/specs/real/email-login-from-other-options.spec.ts b/e2e/specs/real/email-login-from-other-options.spec.ts index 101bf1c3..27737849 100644 --- a/e2e/specs/real/email-login-from-other-options.spec.ts +++ b/e2e/specs/real/email-login-from-other-options.spec.ts @@ -42,7 +42,7 @@ test.describe("pre-release email login smoke", () => { urlTimeout: 20_000, }); - await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Profile" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); }); }); diff --git a/package.json b/package.json index 9bbc58c3..ff780e24 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium", "test:e2e:headed": "playwright test --project=mock-chromium --headed", "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-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: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: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", "contract:check": "node scripts/contracts/check-openapi.mjs", diff --git a/public/images/sidebar/ic_user_vip.png b/public/images/profile/ic_user_vip.png similarity index 100% rename from public/images/sidebar/ic_user_vip.png rename to public/images/profile/ic_user_vip.png diff --git a/public/images/sidebar/ic_user_voicemessage.png b/public/images/profile/ic_user_voicemessage.png similarity index 100% rename from public/images/sidebar/ic_user_voicemessage.png rename to public/images/profile/ic_user_voicemessage.png diff --git a/public/images/sidebar/pic_bg_vip.png b/public/images/profile/pic_bg_vip.png similarity index 100% rename from public/images/sidebar/pic_bg_vip.png rename to public/images/profile/pic_bg_vip.png diff --git a/src/app/_components/__tests__/tailwind-components.test.tsx b/src/app/_components/__tests__/tailwind-components.test.tsx index 1a2b14a6..ee7524d3 100644 --- a/src/app/_components/__tests__/tailwind-components.test.tsx +++ b/src/app/_components/__tests__/tailwind-components.test.tsx @@ -79,4 +79,34 @@ describe("shared Tailwind components", () => { expect(guestHtml).toContain('aria-label="Guest avatar"'); expect(guestHtml).toContain("%2Fimages%2Favatar%2Fplaceholder.png"); }); + + it("renders interactive avatars as accessible buttons", () => { + const characterHtml = renderToStaticMarkup( + undefined} + />, + ); + const userHtml = renderToStaticMarkup( + undefined} + />, + ); + + expect(characterHtml).toContain(' + ); + } + return ( - - {alt} + + {image} ); } diff --git a/src/app/_components/index.ts b/src/app/_components/index.ts index edc9714a..b93bbeb9 100644 --- a/src/app/_components/index.ts +++ b/src/app/_components/index.ts @@ -3,5 +3,6 @@ */ export * from "./back-button"; +export * from "./avatar-interaction"; export * from "./character-avatar"; export * from "./user-message-avatar"; diff --git a/src/app/_components/user-message-avatar.tsx b/src/app/_components/user-message-avatar.tsx index e50a1364..0aa704df 100644 --- a/src/app/_components/user-message-avatar.tsx +++ b/src/app/_components/user-message-avatar.tsx @@ -2,19 +2,30 @@ import Image from "next/image"; -export interface UserMessageAvatarProps { +import type { AvatarInteractionProps } from "./avatar-interaction"; + +interface UserMessageAvatarVisualProps { avatarUrl?: string | null; className?: string; size?: number | string; } +export type UserMessageAvatarProps = UserMessageAvatarVisualProps & + AvatarInteractionProps; + export function UserMessageAvatar({ avatarUrl, className, size = 43, + onClick, + actionLabel, + analyticsKey, }: UserMessageAvatarProps) { 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))", + 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, ] .filter(Boolean) @@ -22,21 +33,30 @@ export function UserMessageAvatar({ const avatarStyle = { width: size, height: size }; const imageSize = typeof size === "number" ? size : 64; - if (avatarUrl && avatarUrl.length > 0) { + const hasUserAvatar = Boolean(avatarUrl && avatarUrl.length > 0); + const image = ( + {hasUserAvatar + ); + + if (onClick) { return ( -
- -
+ {image} + ); } @@ -44,15 +64,9 @@ export function UserMessageAvatar({
- Guest + {image}
); } diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index d701b0ec..67377274 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -15,7 +15,9 @@ import { } from "@/providers/character-provider"; import { useCharacterCatalog } from "@/providers/character-catalog-provider"; import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session"; -import { getCharacterRoutes } from "@/router/routes"; +import { buildGlobalPageUrl } from "@/router/global-route-context"; +import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver"; +import { getCharacterRoutes, ROUTES } from "@/router/routes"; import { MobileShell } from "@/app/_components/core"; @@ -57,6 +59,10 @@ export function ChatScreen() { const refreshCharacterCatalog = characterCatalog.refresh; const defaultCharacterSlug = characterCatalog.defaultCharacter.slug; const characterRoutes = useActiveCharacterRoutes(); + const profileUrl = buildGlobalPageUrl( + ROUTES.profile, + characterRoutes.chat, + ); const searchParams = useSearchParams(); const state = useChatState(); const chatDispatch = useChatDispatch(); @@ -234,6 +240,19 @@ export function ChatScreen() { }); } + function handleOpenUserProfile(): void { + router.push( + resolveAuthenticatedNavigation({ + loginStatus: authState.loginStatus, + targetUrl: profileUrl, + }), + ); + } + + function handleOpenCharacterPrivateRoom(): void { + router.push(characterRoutes.privateRoom); + } + return (
{ chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); }} diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx index 2626bd17..39eb48ad 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -31,7 +31,6 @@ describe("chat Tailwind components", () => { , ); - expect(aiHtml).toContain('aria-label="AI avatar"'); expect(aiHtml).toContain("size-(--chat-avatar-size,43px)"); expect(aiHtml).toContain("size-full object-cover"); expect(aiHtml).toContain("%2Fimages%2Favatar%2Felio.png"); @@ -39,6 +38,28 @@ describe("chat Tailwind components", () => { expect(userHtml).toContain("%2Fuser-avatar.png"); }); + it("renders message avatar actions with explicit destinations", () => { + const aiHtml = renderWithCharacter( + undefined} />, + ); + const userHtml = renderWithCharacter( + undefined} />, + ); + + expect(aiHtml).toContain(' ) : null} diff --git a/src/app/chat/components/message-avatar.tsx b/src/app/chat/components/message-avatar.tsx index 576b1a7e..e2c567ef 100644 --- a/src/app/chat/components/message-avatar.tsx +++ b/src/app/chat/components/message-avatar.tsx @@ -1,32 +1,61 @@ "use client"; -import Image from "next/image"; -import { UserMessageAvatar } from "@/app/_components"; +import { CharacterAvatar, UserMessageAvatar } from "@/app/_components"; import { useActiveCharacter } from "@/providers/character-provider"; export interface MessageAvatarProps { isFromAI: boolean; userAvatarUrl?: string | null; + onClick?: () => void; } const AVATAR_CLASS_NAME = - "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))"; + "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))"; -export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) { +export function MessageAvatar({ + isFromAI, + userAvatarUrl, + onClick, +}: MessageAvatarProps) { const character = useActiveCharacter(); if (isFromAI) { - return ( -
- {character.displayName} -
+ ); + } + + return ( + + ); + } + + if (onClick) { + return ( + ); } diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 32ae6665..18f20fee 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -32,6 +32,8 @@ export interface MessageBubbleProps { onUnlockVoiceMessage?: ChatMessageAction; onUnlockImageMessage?: ChatMessageAction; onOpenImage?: (displayMessageId: string) => void; + onUserAvatarClick?: () => void; + onCharacterAvatarClick?: () => void; } type ChatMessageAction = ( @@ -57,6 +59,8 @@ export function MessageBubble({ onUnlockVoiceMessage, onUnlockImageMessage, onOpenImage, + onUserAvatarClick, + onCharacterAvatarClick, }: MessageBubbleProps) { const avatarUrl = useUserSelector((state) => state.context.avatarUrl); @@ -67,7 +71,10 @@ export function MessageBubble({ data-chat-message-id={displayMessageId} aria-label="AI message" > - +