Compare commits

...

6 Commits

Author SHA1 Message Date
admin 738185f3ac chore(icons): 使用生产环境的图标
Docker Image / Quality and Bundle Budgets (push) Failing after 2m7s
Docker Image / Build and Push Docker Image (push) Has been skipped
2026-07-21 18:39:53 +08:00
admin 7f271ef2e0 feat(chat): show user avatar in profile action
Render the stored user avatar in the Chat Header when available while preserving the existing Profile icon fallback, navigation, accessibility label, and analytics.
2026-07-21 18:36:45 +08:00
admin 9fff5b16fd feat(private-room): add image grid and swipe gallery
Render unlocked albums as adaptive nine-image grids and open the selected source image in the Gallery. Add pointer-driven swipe thresholds, edge resistance, animated snapping, adjacent-image loading, reduced-motion support, and coverage for filtering and navigation.
2026-07-21 18:26:12 +08:00
admin 9deb320cf6 feat(profile)!: replace sidebar and add avatar navigation
Rename the global Sidebar route, UI, assets, analytics, and payment return context to Profile. Add accessible message-avatar navigation and preserve the source character across auth and logout flows.

BREAKING CHANGE: /sidebar has been removed; use /profile instead.
2026-07-21 18:08:12 +08:00
admin 6cd3a0c2d2 feat(docs): add local debugging links for external entry 2026-07-21 17:19:07 +08:00
admin fe954f7098 feat(tip): update image assets for payment interface 2026-07-21 17:18:52 +08:00
88 changed files with 1489 additions and 370 deletions
+1 -1
View File
@@ -224,7 +224,7 @@ payChannel = ezpay
subscriptionType = vip | topup | tip subscriptionType = vip | topup | tip
giftCategory(仅 Tip,可空) giftCategory(仅 Tip,可空)
giftPlanId(仅 Tip,可空) giftPlanId(仅 Tip,可空)
returnTo = chat | private-room | sidebar(可选) returnTo = chat | private-room | profile(可选)
characterSlug(可选) characterSlug(可选)
createdAt createdAt
``` ```
+8 -2
View File
@@ -208,13 +208,19 @@ Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复
任一条件不满足时,页面通过 replace 删除 `album``image`,并保留其他查询参数。 任一条件不满足时,页面通过 replace 删除 `album``image`,并保留其他查询参数。
页面内点击打开 Gallery 时,关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。键盘 Escape 关闭,左右方向键和横向滑动切换图片。 页面内点击九宫格缩略图时,Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。
Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image` 仍使用原始数组索引。横向拖动时图片跟随指针,达到视口宽度 18%(最低 56px),或达到 0.45px/ms 且至少移动 24px 时切换;首尾越界拖动使用 0.28 阻尼且不循环。松手后使用 240ms 横向吸附动画,键盘方向键和左右按钮复用相同切换逻辑。Escape 关闭;`prefers-reduced-motion` 下取消吸附过渡。
## 7. UI 与数据边界 ## 7. UI 与数据边界
- React key 使用稳定 `albumId` - React key 使用稳定 `albumId`
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length` - 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`
- Gallery 只渲染当前索引存在 URL 的图片 - 锁定相册继续只显示模糊封面和解锁入口
- 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引;
- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格;
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
- Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图;
- `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益; - `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益;
- Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存; - Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存;
- Private Room 不持有 Payment ActorTop-up 通过路由级导航进入独立 Payment Provider。 - Private Room 不持有 Payment ActorTop-up 通过路由级导航进入独立 Payment Provider。
+4 -1
View File
@@ -89,4 +89,7 @@ https://<APP_HOST>/external-entry?target=private-room&character=nayeli
`psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。 `psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。
测试环境和正式环境的完整可点击示例见 [外部入口链接清单](./links.md)。 完整可点击示例:
- [本地调试链接清单](./debug-links.md)
- [测试环境和正式环境链接清单](./links.md)
+47
View File
@@ -0,0 +1,47 @@
# 外部入口本地调试链接清单
[返回外部入口接入说明](./README.md)
启动本地应用后,可以直接点击以下链接进行调试。根网址为 `http://localhost:3000`PSID 示例统一使用 `27511427698460020`
## 默认入口
| 入口 | 链接 |
| --- | --- |
| 默认入口(Elio 聊天) | [打开默认入口](http://localhost:3000/external-entry) |
| 普通聊天 | [打开普通聊天](http://localhost:3000/external-entry?target=chat) |
| 携带 PSID 的聊天 | [打开 PSID 聊天示例](http://localhost:3000/external-entry?target=chat&psid=27511427698460020) |
## 角色聊天
| 角色 | 链接 |
| --- | --- |
| Elio | [打开 Elio 聊天](http://localhost:3000/external-entry?target=chat&character=elio) |
| Maya | [打开 Maya 聊天](http://localhost:3000/external-entry?target=chat&character=maya) |
| Nayeli | [打开 Nayeli 聊天](http://localhost:3000/external-entry?target=chat&character=nayeli) |
## 聊天促销
| 促销类型 | 链接 |
| --- | --- |
| 语音促销 | [打开语音促销](http://localhost:3000/external-entry?target=chat&mode=promotion&promotion_type=voice) |
| 图片促销 | [打开图片促销](http://localhost:3000/external-entry?target=chat&mode=promotion&promotion_type=image) |
| 私密文本促销 | [打开私密文本促销](http://localhost:3000/external-entry?target=chat&mode=promotion&promotion_type=private) |
## 咖啡打赏
| 角色 | 链接 |
| --- | --- |
| Elio | [打开 Elio 咖啡打赏](http://localhost:3000/external-entry?target=tip&character=elio) |
| Maya | [打开 Maya 咖啡打赏](http://localhost:3000/external-entry?target=tip&character=maya) |
| Nayeli | [打开 Nayeli 咖啡打赏](http://localhost:3000/external-entry?target=tip&character=nayeli) |
## 私密空间
| 角色 | 链接 |
| --- | --- |
| Elio | [打开 Elio 私密空间](http://localhost:3000/external-entry?target=private-room&character=elio) |
| Maya | [打开 Maya 私密空间](http://localhost:3000/external-entry?target=private-room&character=maya) |
| Nayeli | [打开 Nayeli 私密空间](http://localhost:3000/external-entry?target=private-room&character=nayeli) |
如需为其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。对于不带查询参数的默认入口,请改为追加 `?psid=27511427698460020`
+1 -1
View File
@@ -11,7 +11,7 @@ export const splashStartChatButtonName = "Start Chatting";
export const defaultCharacterSlug = "elio"; export const defaultCharacterSlug = "elio";
export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`; export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`;
export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`; export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`;
export const defaultCharacterSidebarPath = `/sidebar?returnTo=%2Fcharacters%2F${defaultCharacterSlug}%2Fchat`; export const defaultCharacterProfilePath = `/profile?returnTo=%2Fcharacters%2F${defaultCharacterSlug}%2Fchat`;
export const defaultCharacterChatUrl = new RegExp( export const defaultCharacterChatUrl = new RegExp(
`/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`, `/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`,
); );
+1 -1
View File
@@ -27,7 +27,7 @@ export async function enterChatFromSplash(page: Page, options: { expectedUrl?: R
await expect(startChatButton).toBeVisible({ timeout }); await expect(startChatButton).toBeVisible({ timeout });
await expect(startChatButton).toBeEnabled(); await expect(startChatButton).toBeEnabled();
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
await startChatButton.click(); await startChatButton.click({ timeout: 3_000 }).catch(() => {});
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: "Menu" })).toBeVisible(); await expect(page.getByRole("button", { name: "Profile" })).toBeVisible();
}); });
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { import {
clearBrowserState, clearBrowserState,
defaultCharacterSidebarPath, defaultCharacterProfilePath,
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 sidebar after email login", async ({ test("user can log out from the profile after email login", async ({
page, page,
}) => { }) => {
await enterChatFromSplash(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 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: "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( await expect(page).toHaveURL(
new RegExp(defaultCharacterSidebarPath.replace("?", "\\?") + "$"), new RegExp(defaultCharacterProfilePath.replace("?", "\\?") + "$"),
); );
const logoutRequestPromise = page.waitForRequest("**/api/auth/logout"); const logoutRequestPromise = page.waitForRequest("**/api/auth/logout");
@@ -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,
});
}
@@ -42,7 +42,7 @@ test.describe("pre-release email login smoke", () => {
urlTimeout: 20_000, 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(); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
}); });
}); });
+1 -1
View File
@@ -25,7 +25,7 @@
"test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium", "test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium",
"test:e2e:headed": "playwright test --project=mock-chromium --headed", "test:e2e:headed": "playwright test --project=mock-chromium --headed",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-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: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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

@@ -79,4 +79,34 @@ describe("shared Tailwind components", () => {
expect(guestHtml).toContain('aria-label="Guest avatar"'); expect(guestHtml).toContain('aria-label="Guest avatar"');
expect(guestHtml).toContain("%2Fimages%2Favatar%2Fplaceholder.png"); expect(guestHtml).toContain("%2Fimages%2Favatar%2Fplaceholder.png");
}); });
it("renders interactive avatars as accessible buttons", () => {
const characterHtml = renderToStaticMarkup(
<CharacterAvatar
src="/images/avatar/elio.png"
alt="Elio Silvestri"
actionLabel="Open Elio's private room"
analyticsKey="chat.open_private_room_from_avatar"
onClick={() => undefined}
/>,
);
const userHtml = renderToStaticMarkup(
<UserMessageAvatar
actionLabel="Open profile"
analyticsKey="chat.open_profile_from_avatar"
onClick={() => undefined}
/>,
);
expect(characterHtml).toContain('<button type="button"');
expect(characterHtml).toContain(
'aria-label="Open Elio&#x27;s private room"',
);
expect(characterHtml).toContain(
'data-analytics-key="chat.open_private_room_from_avatar"',
);
expect(userHtml).toContain('<button type="button"');
expect(userHtml).toContain('aria-label="Open profile"');
expect(userHtml).toContain("focus-visible:outline-3");
});
}); });
+11
View File
@@ -0,0 +1,11 @@
export type AvatarInteractionProps =
| {
onClick?: undefined;
actionLabel?: never;
analyticsKey?: never;
}
| {
onClick: () => void;
actionLabel: string;
analyticsKey?: string;
};
+47 -18
View File
@@ -3,7 +3,9 @@
import Image from "next/image"; import Image from "next/image";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
export interface CharacterAvatarProps { import type { AvatarInteractionProps } from "./avatar-interaction";
interface CharacterAvatarVisualProps {
src: string; src: string;
alt: string; alt: string;
className?: string; className?: string;
@@ -12,6 +14,9 @@ export interface CharacterAvatarProps {
priority?: boolean; priority?: boolean;
} }
export type CharacterAvatarProps = CharacterAvatarVisualProps &
AvatarInteractionProps;
export function CharacterAvatar({ export function CharacterAvatar({
src, src,
alt, alt,
@@ -19,6 +24,9 @@ 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);
@@ -27,24 +35,45 @@ export function CharacterAvatar({
height: size, height: size,
}; };
const avatarClassName = [
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border-0 bg-(--color-avatar-border,#fbf3f5)",
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)
.join(" ");
const image = (
<Image
src={src}
alt={alt}
width={resolvedImageSize}
height={resolvedImageSize}
priority={priority}
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 ( return (
<span <span className={avatarClassName} style={style}>
className={[ {image}
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--color-avatar-border,#fbf3f5)",
className,
]
.filter(Boolean)
.join(" ")}
style={style}
>
<Image
src={src}
alt={alt}
width={resolvedImageSize}
height={resolvedImageSize}
priority={priority}
className="size-full object-cover"
/>
</span> </span>
); );
} }
+1
View File
@@ -3,5 +3,6 @@
*/ */
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";
+34 -20
View File
@@ -2,19 +2,30 @@
import Image from "next/image"; import Image from "next/image";
export interface UserMessageAvatarProps { import type { AvatarInteractionProps } from "./avatar-interaction";
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)
@@ -22,21 +33,30 @@ 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;
if (avatarUrl && avatarUrl.length > 0) { const hasUserAvatar = Boolean(avatarUrl && avatarUrl.length > 0);
const image = (
<Image
src={hasUserAvatar ? avatarUrl! : "/images/avatar/placeholder.png"}
alt={hasUserAvatar ? "" : "Guest"}
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
);
if (onClick) {
return ( return (
<div <button
type="button"
className={avatarClassName} className={avatarClassName}
style={avatarStyle} style={avatarStyle}
aria-label="User avatar" aria-label={actionLabel}
data-analytics-key={analyticsKey}
data-analytics-label={actionLabel}
onClick={onClick}
> >
<Image {image}
src={avatarUrl} </button>
alt=""
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
</div>
); );
} }
@@ -44,15 +64,9 @@ export function UserMessageAvatar({
<div <div
className={avatarClassName} className={avatarClassName}
style={avatarStyle} style={avatarStyle}
aria-label="Guest avatar" aria-label={hasUserAvatar ? "User avatar" : "Guest avatar"}
> >
<Image {image}
src="/images/avatar/placeholder.png"
alt="Guest"
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
</div> </div>
); );
} }
+22 -1
View File
@@ -15,7 +15,9 @@ 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 { 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"; import { MobileShell } from "@/app/_components/core";
@@ -57,6 +59,10 @@ 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();
@@ -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 ( return (
<MobileShell> <MobileShell>
<div <div
@@ -280,6 +299,8 @@ 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" });
}} }}
@@ -1,6 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCharacterBySlug } from "@/data/constants/character"; import { getCharacterBySlug } from "@/data/constants/character";
import { CharacterProvider } from "@/providers/character-provider"; import { CharacterProvider } from "@/providers/character-provider";
@@ -18,20 +18,33 @@ import { PrivateMessageCard } from "../private-message-card";
import { PwaInstallDialog } from "../pwa-install-dialog"; import { PwaInstallDialog } from "../pwa-install-dialog";
import { TextBubble } from "../text-bubble"; import { TextBubble } from "../text-bubble";
const userMocks = vi.hoisted(() => ({
avatarUrl: null as string | null,
}));
vi.mock("@/router/use-app-navigator", () => ({ vi.mock("@/router/use-app-navigator", () => ({
useAppNavigator: () => ({ useAppNavigator: () => ({
openSubscription: vi.fn(), openSubscription: vi.fn(),
}), }),
})); }));
vi.mock("@/stores/user/user-context", () => ({
useUserSelector: (
selector: (state: { context: { avatarUrl: string | null } }) => unknown,
) => selector({ context: { avatarUrl: userMocks.avatarUrl } }),
}));
describe("chat Tailwind components", () => { describe("chat Tailwind components", () => {
beforeEach(() => {
userMocks.avatarUrl = null;
});
it("renders MessageAvatar AI and user branches with image utilities", () => { it("renders MessageAvatar AI and user branches with image utilities", () => {
const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />); const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />);
const userHtml = renderWithCharacter( const userHtml = renderWithCharacter(
<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");
@@ -39,6 +52,28 @@ describe("chat Tailwind components", () => {
expect(userHtml).toContain("%2Fuser-avatar.png"); expect(userHtml).toContain("%2Fuser-avatar.png");
}); });
it("renders message avatar actions with explicit destinations", () => {
const aiHtml = renderWithCharacter(
<MessageAvatar isFromAI={true} onClick={() => undefined} />,
);
const userHtml = renderWithCharacter(
<MessageAvatar isFromAI={false} onClick={() => undefined} />,
);
expect(aiHtml).toContain('<button type="button"');
expect(aiHtml).toContain(
'data-analytics-key="chat.open_private_room_from_avatar"',
);
expect(aiHtml).toContain(
'aria-label="Open Elio Silvestri&#x27;s private room"',
);
expect(userHtml).toContain('<button type="button"');
expect(userHtml).toContain(
'data-analytics-key="chat.open_profile_from_avatar"',
);
expect(userHtml).toContain('aria-label="Open profile"');
});
it("renders the active character avatar", () => { it("renders the active character avatar", () => {
const maya = getCharacterBySlug("maya"); const maya = getCharacterBySlug("maya");
expect(maya).not.toBeNull(); expect(maya).not.toBeNull();
@@ -196,17 +231,18 @@ 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="Menu"'); expect(guestHtml).not.toContain('aria-label="Profile"');
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="Menu"'); expect(memberHtml).toContain('aria-label="Profile"');
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"'); expect(memberHtml).toContain('data-analytics-key="chat.open_profile"');
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)");
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]"); expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)"); expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
expect(memberHtml).toContain("lucide-user-round");
expect(memberWithHintHtml).toContain( expect(memberWithHintHtml).toContain(
'data-analytics-key="chat.external_browser_hint"', 'data-analytics-key="chat.external_browser_hint"',
); );
@@ -222,12 +258,26 @@ 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="Menu"')); ).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Profile"'));
expect(guestWithHintHtml).not.toContain( expect(guestWithHintHtml).not.toContain(
'data-analytics-key="chat.external_browser_hint"', 'data-analytics-key="chat.external_browser_hint"',
); );
}); });
it("renders the user avatar in the ChatHeader Profile action", () => {
userMocks.avatarUrl = "/images/avatar/profile-user.png";
const html = renderWithCharacter(<ChatHeader isGuest={false} />);
expect(html).toContain("%2Fimages%2Favatar%2Fprofile-user.png");
expect(html).toContain('aria-label="Profile"');
expect(html).toContain('data-analytics-key="chat.open_profile"');
expect(html).toContain(
"var(--responsive-icon-button-size, 42px)",
);
expect(html).not.toContain("lucide-user-round");
});
it("links guest chat back to the active character splash", () => { it("links guest chat back to the active character splash", () => {
const maya = getCharacterBySlug("maya"); const maya = getCharacterBySlug("maya");
if (!maya) throw new Error("Missing Maya character fixture"); if (!maya) throw new Error("Missing Maya character fixture");
@@ -239,7 +289,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="Menu"'); expect(html).not.toContain('aria-label="Profile"');
expect(html).not.toContain( expect(html).not.toContain(
'data-analytics-key="chat.external_browser_hint"', 'data-analytics-key="chat.external_browser_hint"',
); );
+10
View File
@@ -59,6 +59,8 @@ 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 = (
@@ -81,6 +83,8 @@ 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);
@@ -251,6 +255,8 @@ export function ChatArea({
onUnlockVoiceMessage, onUnlockVoiceMessage,
onUnlockImageMessage, onUnlockImageMessage,
onOpenImage, onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
)} )}
{isReplyingAI ? ( {isReplyingAI ? (
@@ -287,6 +293,8 @@ 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 +322,8 @@ function renderMessagesWithDateHeaders(
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage} onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
onUserAvatarClick={onUserAvatarClick}
onCharacterAvatarClick={onCharacterAvatarClick}
/> />
), ),
); );
+28 -18
View File
@@ -2,17 +2,17 @@
/** /**
* ChatHeader 顶部栏 * ChatHeader 顶部栏
* *
* 图标:lucide-react <Lock />(游客 banner+ <Menu />(菜单按钮) * Profile 优先展示用户头像;头像缺失时回退到 lucide-react <UserRound />。
* tree-shakablecurrentColor 继承父 color
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Lock, Menu } from "lucide-react"; import { Lock, UserRound } from "lucide-react";
import { BackButton } from "@/app/_components"; import { BackButton, UserMessageAvatar } from "@/app/_components";
import { useActiveCharacterRoutes } from "@/providers/character-provider"; import { useActiveCharacterRoutes } from "@/providers/character-provider";
import { buildGlobalPageUrl } from "@/router/global-route-context"; import { buildGlobalPageUrl } from "@/router/global-route-context";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useUserSelector } from "@/stores/user/user-context";
import { BrowserHintOverlay } from "./browser-hint-overlay"; import { BrowserHintOverlay } from "./browser-hint-overlay";
@@ -29,6 +29,10 @@ export function ChatHeader({
}: ChatHeaderProps) { }: ChatHeaderProps) {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
const handleOpenProfile = () => {
navigator.push(buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat));
};
return ( return (
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0"> <header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
@@ -72,20 +76,26 @@ export function ChatHeader({
{showBrowserHint ? <BrowserHintOverlay /> : null} {showBrowserHint ? <BrowserHintOverlay /> : null}
</div> </div>
<button {avatarUrl ? (
type="button" <UserMessageAvatar
data-analytics-key="chat.open_menu" avatarUrl={avatarUrl}
data-analytics-label="Open chat menu" size="var(--responsive-icon-button-size, 42px)"
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]" actionLabel="Profile"
onClick={() => analyticsKey="chat.open_profile"
navigator.push( onClick={handleOpenProfile}
buildGlobalPageUrl(ROUTES.sidebar, characterRoutes.chat), />
) ) : (
} <button
aria-label="Menu" type="button"
> data-analytics-key="chat.open_profile"
<Menu size={24} aria-hidden="true" /> data-analytics-label="Open profile"
</button> 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={handleOpenProfile}
aria-label="Profile"
>
<UserRound size={24} aria-hidden="true" />
</button>
)}
</> </>
) : null} ) : null}
</div> </div>
+40 -11
View File
@@ -1,32 +1,61 @@
"use client"; "use client";
import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components"; import { CharacterAvatar, 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 =
"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(); const character = useActiveCharacter();
if (isFromAI) { if (isFromAI) {
return ( if (onClick) {
<div className={AVATAR_CLASS_NAME} aria-label="AI avatar"> return (
<Image <CharacterAvatar
src={character.assets.avatar} src={character.assets.avatar}
alt={character.displayName} alt={character.displayName}
width={43} size="var(--chat-avatar-size, 43px)"
height={43} imageSize={43}
priority priority
className="size-full object-cover" className={AVATAR_CLASS_NAME}
actionLabel={`Open ${character.displayName}'s private room`}
analyticsKey="chat.open_private_room_from_avatar"
onClick={onClick}
/> />
</div> );
}
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}
/>
); );
} }
+13 -2
View File
@@ -32,6 +32,8 @@ export interface MessageBubbleProps {
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction; onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (displayMessageId: string) => void;
onUserAvatarClick?: () => void;
onCharacterAvatarClick?: () => void;
} }
type ChatMessageAction = ( type ChatMessageAction = (
@@ -57,6 +59,8 @@ export function MessageBubble({
onUnlockVoiceMessage, onUnlockVoiceMessage,
onUnlockImageMessage, onUnlockImageMessage,
onOpenImage, onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const avatarUrl = useUserSelector((state) => state.context.avatarUrl); const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
@@ -67,7 +71,10 @@ export function MessageBubble({
data-chat-message-id={displayMessageId} data-chat-message-id={displayMessageId}
aria-label="AI message" aria-label="AI message"
> >
<MessageAvatar isFromAI={true} /> <MessageAvatar
isFromAI={true}
onClick={onCharacterAvatarClick}
/>
<div className={styles.bubbleInlineSpacer} aria-hidden="true" /> <div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent <MessageContent
characterId={characterId} characterId={characterId}
@@ -120,7 +127,11 @@ export function MessageBubble({
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
/> />
<div className={styles.bubbleInlineSpacer} aria-hidden="true" /> <div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} /> <MessageAvatar
isFromAI={false}
userAvatarUrl={avatarUrl}
onClick={onUserAvatarClick}
/>
</div> </div>
); );
} }
@@ -22,14 +22,14 @@ vi.mock("@/stores/user/user-context", () => ({
})); }));
describe("CoinsRulesScreen", () => { describe("CoinsRulesScreen", () => {
it("renders outside CharacterProvider and returns to global sidebar", () => { it("renders outside CharacterProvider and returns to global profile", () => {
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="/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat"', 'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
); );
}); });
}); });
+2 -2
View File
@@ -118,9 +118,9 @@ export function CoinsRulesScreen({ returnTo }: CoinsRulesScreenProps) {
<main className={styles.screen}> <main className={styles.screen}>
<header className={styles.header}> <header className={styles.header}>
<BackButton <BackButton
href={navigation.sidebarUrl} href={navigation.profileUrl}
variant="soft" variant="soft"
aria-label="Back to sidebar" aria-label="Back to profile"
/> />
<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_sidebar"]', '[data-analytics-key="feedback.back_to_profile"]',
)?.getAttribute("href"), )?.getAttribute("href"),
).toBe("/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat"); ).toBe("/profile?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 Sidebar"); expect(container.textContent).toContain("Back to Profile");
}); });
}); });
+6 -6
View File
@@ -77,11 +77,11 @@ export function FeedbackScreen({ returnTo }: FeedbackScreenProps) {
</div> </div>
<button <button
type="button" type="button"
data-analytics-key="feedback.back_to_sidebar" data-analytics-key="feedback.back_to_profile"
className={styles.primaryButton} className={styles.primaryButton}
onClick={() => navigator.replace(navigation.sidebarUrl)} onClick={() => navigator.replace(navigation.profileUrl)}
> >
Back to Sidebar Back to Profile
</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.sidebarUrl} href={navigation.profileUrl}
variant="soft" variant="soft"
ariaLabel="Back to sidebar" ariaLabel="Back to profile"
analyticsKey="feedback.back_to_sidebar" analyticsKey="feedback.back_to_profile"
/> />
<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>
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
getResistedGalleryDragDistance,
resolveGallerySwipeDirection,
} from "../private-album-gallery-motion";
describe("private album Gallery motion", () => {
it("switches after crossing the viewport distance threshold", () => {
expect(
resolveGallerySwipeDirection({
distance: -72,
durationMs: 500,
viewportWidth: 390,
hasPrevious: true,
hasNext: true,
}),
).toBe(1);
expect(
resolveGallerySwipeDirection({
distance: 72,
durationMs: 500,
viewportWidth: 390,
hasPrevious: true,
hasNext: true,
}),
).toBe(-1);
});
it("switches on a short fast swipe but not a slow short drag", () => {
const baseInput = {
distance: -30,
viewportWidth: 390,
hasPrevious: true,
hasNext: true,
};
expect(
resolveGallerySwipeDirection({ ...baseInput, durationMs: 50 }),
).toBe(1);
expect(
resolveGallerySwipeDirection({ ...baseInput, durationMs: 500 }),
).toBe(0);
});
it("does not move beyond the first or last image", () => {
expect(
resolveGallerySwipeDirection({
distance: 100,
durationMs: 100,
viewportWidth: 390,
hasPrevious: false,
hasNext: true,
}),
).toBe(0);
expect(
resolveGallerySwipeDirection({
distance: -100,
durationMs: 100,
viewportWidth: 390,
hasPrevious: true,
hasNext: false,
}),
).toBe(0);
});
it("applies resistance only while pulling past an edge", () => {
expect(getResistedGalleryDragDistance(100, false, true)).toBeCloseTo(28);
expect(getResistedGalleryDragDistance(-100, true, false)).toBeCloseTo(-28);
expect(getResistedGalleryDragDistance(-100, false, true)).toBe(-100);
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { PrivateAlbumSchema } from "@/data/schemas/private-room";
import {
findPrivateAlbumDisplayImage,
getPrivateAlbumDisplayImages,
getPrivateAlbumGridLayout,
} from "../private-album-images";
describe("private album display images", () => {
const album = PrivateAlbumSchema.parse({
albumId: "album-1",
images: [
{ url: " /images/photo-1.png ", locked: false, index: 0 },
{ url: "/images/photo-2.png", locked: true, index: 1 },
{ url: " ", locked: false, index: 2 },
{ url: "/images/photo-4.png", locked: false, index: 3 },
],
unlocked: true,
lockDetail: { locked: false },
});
it("filters unavailable images while preserving source indexes", () => {
expect(getPrivateAlbumDisplayImages(album)).toEqual([
{ sourceIndex: 0, url: "/images/photo-1.png" },
{ sourceIndex: 3, url: "/images/photo-4.png" },
]);
});
it("resolves Gallery images by their original source index", () => {
expect(findPrivateAlbumDisplayImage(album, 3)).toEqual({
sourceIndex: 3,
url: "/images/photo-4.png",
});
expect(findPrivateAlbumDisplayImage(album, 1)).toBeNull();
expect(findPrivateAlbumDisplayImage(album, 2)).toBeNull();
});
it.each([
[0, "empty"],
[1, "single"],
[2, "double"],
[3, "triple"],
[4, "double"],
[5, "triple"],
[9, "triple"],
] as const)("uses the expected grid for %i images", (count, layout) => {
expect(getPrivateAlbumGridLayout(count)).toBe(layout);
});
});
@@ -0,0 +1,67 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PrivateAlbumSchema } from "@/data/schemas/private-room";
import { PrivateAlbumCard } from "../private-album-card";
describe("PrivateAlbumCard interactions", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("opens the selected thumbnail with its original source index", () => {
const onOpenGallery = vi.fn();
const album = PrivateAlbumSchema.parse({
albumId: "album-1",
title: "Private afternoon",
imageCount: 4,
images: [
{ url: "", locked: false, index: 0 },
{ url: "/images/private-room/locked.png", locked: true, index: 1 },
{ url: "/images/private-room/photo-2.png", locked: false, index: 2 },
{ url: "/images/private-room/photo-3.png", locked: false, index: 3 },
],
locked: false,
unlocked: true,
lockDetail: { locked: false },
});
act(() => {
root.render(
<PrivateAlbumCard
album={album}
displayName="Elio Silvestri"
avatarUrl="/images/avatar/elio.png"
index={0}
isUnlocking={false}
onOpenGallery={onOpenGallery}
onUnlock={() => undefined}
/>,
);
});
const thumbnail = container.querySelector<HTMLButtonElement>(
'button[data-image-index="3"]',
);
act(() => thumbnail?.click());
expect(onOpenGallery).toHaveBeenCalledOnce();
expect(onOpenGallery).toHaveBeenCalledWith(3);
});
});
@@ -43,10 +43,10 @@ function renderCard(album: PrivateAlbum): string {
} }
describe("PrivateAlbumCard", () => { describe("PrivateAlbumCard", () => {
it("renders only the first unlocked image and its photo count", () => { it("renders every available unlocked image in the adaptive grid", () => {
const html = renderCard( const html = renderCard(
makeAlbum({ makeAlbum({
imageCount: 8, imageCount: 2,
images: [ images: [
{ url: COVER_URL, locked: false, index: 0 }, { url: COVER_URL, locked: false, index: 0 },
{ {
@@ -59,13 +59,48 @@ describe("PrivateAlbumCard", () => {
); );
expect(html).toContain("elio.png"); expect(html).toContain("elio.png");
expect(html).not.toContain("%2Fimages%2Fcover%2Felio.png"); expect(html).toContain("%2Fimages%2Fcover%2Felio.png");
expect(html).toContain("8 photos"); expect(html).toContain('data-grid-layout="double"');
expect(html).toContain('data-image-index="0"');
expect(html).toContain('data-image-index="1"');
expect(html).toContain("A quiet morning by the water."); expect(html).toContain("A quiet morning by the water.");
expect(html).toContain('aria-label="Open 8 private album photos"'); expect(html).toContain(
'aria-label="Open photo 2 of 2 from Elio Silvestri"',
);
expect(html).toContain('data-analytics-key="private_album.open_gallery"'); expect(html).toContain('data-analytics-key="private_album.open_gallery"');
}); });
it.each([
{ count: 1, layout: "single" },
{ count: 2, layout: "double" },
{ count: 3, layout: "triple" },
{ count: 4, layout: "double" },
{ count: 5, layout: "triple" },
{ count: 9, layout: "triple" },
])("uses the $layout layout for $count images", ({ count, layout }) => {
const html = renderCard(
makeAlbum({
imageCount: count,
images: makeImages(count),
}),
);
expect(html).toContain(`data-grid-layout="${layout}"`);
});
it("limits the preview to nine images and shows the remaining count", () => {
const html = renderCard(
makeAlbum({
imageCount: 12,
images: makeImages(12),
}),
);
expect(html.match(/data-image-index=/g)).toHaveLength(9);
expect(html).toContain("+3");
expect(html).not.toContain('data-image-index="9"');
});
it("uses the backend first image as the locked cover", () => { it("uses the backend first image as the locked cover", () => {
const html = renderCard( const html = renderCard(
makeAlbum({ makeAlbum({
@@ -93,3 +128,11 @@ describe("PrivateAlbumCard", () => {
expect(html).toContain("No photo available"); expect(html).toContain("No photo available");
}); });
}); });
function makeImages(count: number) {
return Array.from({ length: count }, (_, index) => ({
url: `/images/private-room/photo-${index}.png`,
locked: false,
index,
}));
}
@@ -0,0 +1,154 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PrivateAlbumSchema } from "@/data/schemas/private-room";
import { PrivateAlbumGallery } from "../private-album-gallery";
const album = PrivateAlbumSchema.parse({
albumId: "album-1",
title: "Private afternoon",
imageCount: 5,
images: [
{ url: "/images/private-room/photo-0.png", locked: false, index: 0 },
{ url: "", locked: false, index: 1 },
{ url: "/images/private-room/locked.png", locked: true, index: 2 },
{ url: "/images/private-room/photo-3.png", locked: false, index: 3 },
{ url: "/images/private-room/photo-4.png", locked: false, index: 4 },
],
locked: false,
unlocked: true,
lockDetail: { locked: false },
});
describe("PrivateAlbumGallery", () => {
let container: HTMLDivElement;
let root: Root;
let onClose: () => void;
let onImageIndexChange: (index: number) => void;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperties(HTMLElement.prototype, {
setPointerCapture: {
configurable: true,
value: vi.fn(),
},
hasPointerCapture: {
configurable: true,
value: vi.fn(() => true),
},
releasePointerCapture: {
configurable: true,
value: vi.fn(),
},
});
onClose = vi.fn();
onImageIndexChange = vi.fn();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("follows a drag and switches to the next available source index", () => {
renderGallery(0);
const viewport = getViewport();
Object.defineProperty(viewport, "clientWidth", {
configurable: true,
value: 390,
});
dispatchPointer(viewport, "pointerdown", 300);
dispatchPointer(viewport, "pointermove", 200);
expect(viewport.querySelector<HTMLElement>("[aria-hidden=false]")?.style.getPropertyValue("--gallery-drag-offset")).toBe("-100px");
dispatchPointer(viewport, "pointerup", 200);
expect(onImageIndexChange).toHaveBeenCalledWith(3);
expect(container.textContent).toContain("2 / 3");
});
it("snaps back on pointer cancellation without changing images", () => {
renderGallery(3);
const viewport = getViewport();
Object.defineProperty(viewport, "clientWidth", {
configurable: true,
value: 390,
});
dispatchPointer(viewport, "pointerdown", 300);
dispatchPointer(viewport, "pointermove", 200);
dispatchPointer(viewport, "pointercancel", 200);
expect(onImageIndexChange).not.toHaveBeenCalled();
expect(container.textContent).toContain("2 / 3");
});
it("uses the same available indexes for controls and keyboard navigation", () => {
renderGallery(3);
act(() => {
container
.querySelector<HTMLButtonElement>('button[aria-label="Next photo"]')
?.click();
});
expect(onImageIndexChange).toHaveBeenLastCalledWith(4);
act(() => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }),
);
});
expect(onImageIndexChange).toHaveBeenLastCalledWith(3);
act(() => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
});
expect(onClose).toHaveBeenCalledOnce();
});
function renderGallery(imageIndex: number): void {
act(() => {
root.render(
<PrivateAlbumGallery
album={album}
imageIndex={imageIndex}
onClose={onClose}
onImageIndexChange={onImageIndexChange}
/>,
);
});
}
function getViewport(): HTMLDivElement {
const viewport = container.querySelector<HTMLDivElement>(
'[data-testid="private-album-gallery-viewport"]',
);
if (!viewport) throw new Error("Missing Gallery viewport");
return viewport;
}
});
function dispatchPointer(
target: HTMLElement,
type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel",
clientX: number,
): void {
const event = new MouseEvent(type, {
bubbles: true,
button: 0,
clientX,
});
Object.defineProperty(event, "pointerId", { value: 1 });
act(() => target.dispatchEvent(event));
}
@@ -6,6 +6,12 @@ import { CharacterAvatar } from "@/app/_components";
import type { PrivateAlbum } from "@/data/schemas/private-room"; import type { PrivateAlbum } from "@/data/schemas/private-room";
import { isPrivateAlbumLocked } from "@/lib/private-room/private_album"; import { isPrivateAlbumLocked } from "@/lib/private-room/private_album";
import {
getPrivateAlbumDisplayImages,
getPrivateAlbumGridLayout,
PRIVATE_ALBUM_GRID_LIMIT,
} from "../private-album-images";
import styles from "../private-room-screen.module.css"; import styles from "../private-room-screen.module.css";
export interface PrivateAlbumCardProps { export interface PrivateAlbumCardProps {
@@ -14,7 +20,7 @@ export interface PrivateAlbumCardProps {
avatarUrl: string; avatarUrl: string;
index: number; index: number;
isUnlocking: boolean; isUnlocking: boolean;
onOpenGallery: () => void; onOpenGallery: (imageIndex: number) => void;
onUnlock: () => void; onUnlock: () => void;
} }
@@ -31,6 +37,19 @@ export function PrivateAlbumCard({
const firstImage = album.images[0]; const firstImage = album.images[0];
const firstImageUrl = firstImage?.url || null; const firstImageUrl = firstImage?.url || null;
const photoCount = album.imageCount || album.images.length; const photoCount = album.imageCount || album.images.length;
const displayImages = getPrivateAlbumDisplayImages(album);
const previewImages = displayImages.slice(0, PRIVATE_ALBUM_GRID_LIMIT);
const gridLayout = getPrivateAlbumGridLayout(previewImages.length);
const overflowCount = Math.max(
0,
displayImages.length - PRIVATE_ALBUM_GRID_LIMIT,
);
const gridLayoutClassName =
gridLayout === "single"
? styles.mediaGridSingle
: gridLayout === "double"
? styles.mediaGridDouble
: styles.mediaGridTriple;
return ( return (
<article <article
@@ -92,30 +111,39 @@ export function PrivateAlbumCard({
</div> </div>
</div> </div>
</button> </button>
) : firstImageUrl ? ( ) : previewImages.length > 0 ? (
<button <div
type="button" className={`${styles.mediaGrid} ${gridLayoutClassName}`}
data-analytics-key="private_album.open_gallery" data-grid-layout={gridLayout}
data-analytics-label="Open private album gallery" data-image-count={displayImages.length}
className={styles.mediaCover}
onClick={onOpenGallery}
aria-label={`Open ${photoCount} private album photos`}
> >
<Image {previewImages.map((image, imagePosition) => (
src={firstImageUrl} <button
alt={`${album.title || displayName} private album`} type="button"
width={720} key={`${image.sourceIndex}:${image.url}`}
height={900} data-analytics-key="private_album.open_gallery"
className={styles.momentCoverImage} data-analytics-label="Open private album gallery"
sizes="(max-width: 540px) calc(100vw - 36px), 484px" data-image-index={image.sourceIndex}
/> className={styles.mediaGridItem}
{photoCount > 1 ? ( onClick={() => onOpenGallery(image.sourceIndex)}
<span className={styles.mediaCountBadge}> aria-label={`Open photo ${imagePosition + 1} of ${displayImages.length} from ${displayName}`}
<ImageIcon size={13} aria-hidden="true" /> >
{photoCount} photos <Image
</span> src={image.url}
) : null} alt=""
</button> fill
sizes={getGridImageSizes(gridLayout)}
className={styles.mediaGridImage}
/>
{imagePosition === PRIVATE_ALBUM_GRID_LIMIT - 1 &&
overflowCount > 0 ? (
<span className={styles.mediaGridOverflow} aria-hidden="true">
+{overflowCount}
</span>
) : null}
</button>
))}
</div>
) : ( ) : (
<div <div
className={styles.emptyMediaCover} className={styles.emptyMediaCover}
@@ -132,6 +160,12 @@ export function PrivateAlbumCard({
); );
} }
function getGridImageSizes(layout: string): string {
if (layout === "single") return "(max-width: 540px) 68vw, 280px";
if (layout === "double") return "(max-width: 540px) 34vw, 154px";
return "(max-width: 540px) 29vw, 150px";
}
function formatAlbumTime(value: string | null): string { function formatAlbumTime(value: string | null): string {
if (!value) return ""; if (!value) return "";
const date = new Date(value); const date = new Date(value);
@@ -1,11 +1,23 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import Image from "next/image"; import Image from "next/image";
import { ChevronLeft, ChevronRight, X } from "lucide-react"; import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { PrivateAlbum } from "@/data/schemas/private-room"; import type { PrivateAlbum } from "@/data/schemas/private-room";
import {
getResistedGalleryDragDistance,
resolveGallerySwipeDirection,
} from "../private-album-gallery-motion";
import { getPrivateAlbumDisplayImages } from "../private-album-images";
import styles from "../private-room-screen.module.css"; import styles from "../private-room-screen.module.css";
export interface PrivateAlbumGalleryProps { export interface PrivateAlbumGalleryProps {
@@ -15,41 +27,137 @@ export interface PrivateAlbumGalleryProps {
onImageIndexChange: (index: number) => void; onImageIndexChange: (index: number) => void;
} }
interface ActivePointerGesture {
pointerId: number;
startX: number;
startTime: number;
startPosition: number;
viewportWidth: number;
}
type GallerySlideStyle = CSSProperties & {
"--gallery-slide-offset": string;
"--gallery-drag-offset": string;
};
export function PrivateAlbumGallery({ export function PrivateAlbumGallery({
album, album,
imageIndex, imageIndex,
onClose, onClose,
onImageIndexChange, onImageIndexChange,
}: PrivateAlbumGalleryProps) { }: PrivateAlbumGalleryProps) {
const touchStartXRef = useRef<number | null>(null); const displayImages = useMemo(
const currentImage = album.images[imageIndex]; () => getPrivateAlbumDisplayImages(album),
const hasPrevious = imageIndex > 0; [album],
const hasNext = imageIndex < album.images.length - 1; );
const [activeSourceIndex, setActiveSourceIndex] = useState(imageIndex);
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const gestureRef = useRef<ActivePointerGesture | null>(null);
const activePosition = displayImages.findIndex(
(image) => image.sourceIndex === activeSourceIndex,
);
const currentImage = displayImages[activePosition];
const hasPrevious = activePosition > 0;
const hasNext =
activePosition >= 0 && activePosition < displayImages.length - 1;
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose(); if (event.key === "Escape") onClose();
if (event.key === "ArrowLeft" && hasPrevious) { if (event.key === "ArrowLeft" && hasPrevious) {
onImageIndexChange(imageIndex - 1); const previousImage = displayImages[activePosition - 1];
if (previousImage) {
setActiveSourceIndex(previousImage.sourceIndex);
onImageIndexChange(previousImage.sourceIndex);
}
} }
if (event.key === "ArrowRight" && hasNext) { if (event.key === "ArrowRight" && hasNext) {
onImageIndexChange(imageIndex + 1); const nextImage = displayImages[activePosition + 1];
if (nextImage) {
setActiveSourceIndex(nextImage.sourceIndex);
onImageIndexChange(nextImage.sourceIndex);
}
} }
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [hasNext, hasPrevious, imageIndex, onClose, onImageIndexChange]); }, [
activePosition,
displayImages,
hasNext,
hasPrevious,
onClose,
onImageIndexChange,
]);
if (!currentImage?.url) return null; if (!currentImage) return null;
const handleTouchEnd = (event: React.TouchEvent) => { const changeToPosition = (position: number) => {
const touchStartX = touchStartXRef.current; const nextImage = displayImages[position];
touchStartXRef.current = null; if (!nextImage || position === activePosition) return;
if (touchStartX === null) return; setActiveSourceIndex(nextImage.sourceIndex);
const delta = event.changedTouches[0]?.clientX ?? touchStartX; setDragOffset(0);
const distance = delta - touchStartX; setIsDragging(false);
if (distance > 48 && hasPrevious) onImageIndexChange(imageIndex - 1); onImageIndexChange(nextImage.sourceIndex);
if (distance < -48 && hasNext) onImageIndexChange(imageIndex + 1); };
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || displayImages.length <= 1) return;
const viewportWidth = event.currentTarget.clientWidth || window.innerWidth;
gestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startTime: event.timeStamp,
startPosition: activePosition,
viewportWidth,
};
event.currentTarget.setPointerCapture(event.pointerId);
setDragOffset(0);
setIsDragging(true);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
event.preventDefault();
const distance = event.clientX - gesture.startX;
setDragOffset(
getResistedGalleryDragDistance(
distance,
gesture.startPosition > 0,
gesture.startPosition < displayImages.length - 1,
),
);
};
const finishPointerGesture = (
event: ReactPointerEvent<HTMLDivElement>,
cancelled: boolean,
) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
gestureRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const distance = event.clientX - gesture.startX;
const direction = cancelled
? 0
: resolveGallerySwipeDirection({
distance,
durationMs: event.timeStamp - gesture.startTime,
viewportWidth: gesture.viewportWidth,
hasPrevious: gesture.startPosition > 0,
hasNext: gesture.startPosition < displayImages.length - 1,
});
setIsDragging(false);
setDragOffset(0);
if (direction !== 0) {
changeToPosition(gesture.startPosition + direction);
}
}; };
return ( return (
@@ -58,10 +166,6 @@ export function PrivateAlbumGallery({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={album.title || "Private album gallery"} aria-label={album.title || "Private album gallery"}
onTouchStart={(event) => {
touchStartXRef.current = event.touches[0]?.clientX ?? null;
}}
onTouchEnd={handleTouchEnd}
> >
<button <button
type="button" type="button"
@@ -72,27 +176,54 @@ export function PrivateAlbumGallery({
<X size={24} aria-hidden="true" /> <X size={24} aria-hidden="true" />
</button> </button>
<div className={styles.galleryImageFrame}> <div
<Image className={styles.galleryViewport}
src={currentImage.url} data-testid="private-album-gallery-viewport"
alt={`${album.title || "Private album"} photo ${imageIndex + 1}`} data-dragging={isDragging ? "true" : "false"}
fill onPointerDown={handlePointerDown}
priority onPointerMove={handlePointerMove}
draggable={false} onPointerUp={(event) => finishPointerGesture(event, false)}
sizes="(max-width: 540px) 100vw, 540px" onPointerCancel={(event) => finishPointerGesture(event, true)}
className={styles.galleryImage} onLostPointerCapture={(event) => finishPointerGesture(event, true)}
/> >
{displayImages.map((image, position) => {
if (Math.abs(position - activePosition) > 1) return null;
const slideStyle = {
"--gallery-slide-offset": `${(position - activePosition) * 100}%`,
"--gallery-drag-offset": `${dragOffset}px`,
} as GallerySlideStyle;
return (
<div
key={`${image.sourceIndex}:${image.url}`}
className={styles.gallerySlide}
style={slideStyle}
aria-hidden={position !== activePosition}
>
<Image
src={image.url}
alt={`${album.title || "Private album"} photo ${position + 1}`}
fill
loading="eager"
fetchPriority={position === activePosition ? "high" : "auto"}
draggable={false}
sizes="(max-width: 540px) 100vw, 540px"
className={styles.galleryImage}
/>
</div>
);
})}
</div> </div>
<span className={styles.galleryCounter}> <span className={styles.galleryCounter} aria-live="polite">
{imageIndex + 1} / {album.images.length} {activePosition + 1} / {displayImages.length}
</span> </span>
{hasPrevious ? ( {hasPrevious ? (
<button <button
type="button" type="button"
className={`${styles.galleryNav} ${styles.galleryPrevious}`} className={`${styles.galleryNav} ${styles.galleryPrevious}`}
onClick={() => onImageIndexChange(imageIndex - 1)} onClick={() => changeToPosition(activePosition - 1)}
aria-label="Previous photo" aria-label="Previous photo"
> >
<ChevronLeft size={30} aria-hidden="true" /> <ChevronLeft size={30} aria-hidden="true" />
@@ -102,7 +233,7 @@ export function PrivateAlbumGallery({
<button <button
type="button" type="button"
className={`${styles.galleryNav} ${styles.galleryNext}`} className={`${styles.galleryNav} ${styles.galleryNext}`}
onClick={() => onImageIndexChange(imageIndex + 1)} onClick={() => changeToPosition(activePosition + 1)}
aria-label="Next photo" aria-label="Next photo"
> >
<ChevronRight size={30} aria-hidden="true" /> <ChevronRight size={30} aria-hidden="true" />
@@ -0,0 +1,54 @@
export const GALLERY_DISTANCE_RATIO = 0.18;
export const GALLERY_MIN_DISTANCE_PX = 56;
export const GALLERY_FAST_SWIPE_MIN_DISTANCE_PX = 24;
export const GALLERY_FAST_SWIPE_VELOCITY_PX_PER_MS = 0.45;
export const GALLERY_EDGE_RESISTANCE = 0.28;
export type GallerySwipeDirection = -1 | 0 | 1;
export interface ResolveGallerySwipeInput {
distance: number;
durationMs: number;
viewportWidth: number;
hasPrevious: boolean;
hasNext: boolean;
}
export function getResistedGalleryDragDistance(
distance: number,
hasPrevious: boolean,
hasNext: boolean,
): number {
if (distance > 0 && !hasPrevious) {
return distance * GALLERY_EDGE_RESISTANCE;
}
if (distance < 0 && !hasNext) {
return distance * GALLERY_EDGE_RESISTANCE;
}
return distance;
}
export function resolveGallerySwipeDirection({
distance,
durationMs,
viewportWidth,
hasPrevious,
hasNext,
}: ResolveGallerySwipeInput): GallerySwipeDirection {
if (distance === 0) return 0;
const absoluteDistance = Math.abs(distance);
const distanceThreshold = Math.max(
GALLERY_MIN_DISTANCE_PX,
Math.max(0, viewportWidth) * GALLERY_DISTANCE_RATIO,
);
const velocity = absoluteDistance / Math.max(1, durationMs);
const passedDistanceThreshold = absoluteDistance >= distanceThreshold;
const passedVelocityThreshold =
absoluteDistance >= GALLERY_FAST_SWIPE_MIN_DISTANCE_PX &&
velocity >= GALLERY_FAST_SWIPE_VELOCITY_PX_PER_MS;
if (!passedDistanceThreshold && !passedVelocityThreshold) return 0;
if (distance > 0) return hasPrevious ? -1 : 0;
return hasNext ? 1 : 0;
}
@@ -0,0 +1,44 @@
import type { PrivateAlbum } from "@/data/schemas/private-room";
export const PRIVATE_ALBUM_GRID_LIMIT = 9;
export interface PrivateAlbumDisplayImage {
readonly sourceIndex: number;
readonly url: string;
}
export type PrivateAlbumGridLayout =
| "empty"
| "single"
| "double"
| "triple";
export function getPrivateAlbumDisplayImages(
album: PrivateAlbum,
): readonly PrivateAlbumDisplayImage[] {
return album.images.flatMap((image, sourceIndex) => {
const url = image.url.trim();
if (image.locked || url.length === 0) return [];
return [{ sourceIndex, url }];
});
}
export function findPrivateAlbumDisplayImage(
album: PrivateAlbum,
sourceIndex: number,
): PrivateAlbumDisplayImage | null {
return (
getPrivateAlbumDisplayImages(album).find(
(image) => image.sourceIndex === sourceIndex,
) ?? null
);
}
export function getPrivateAlbumGridLayout(
imageCount: number,
): PrivateAlbumGridLayout {
if (imageCount <= 0) return "empty";
if (imageCount === 1) return "single";
if (imageCount === 2 || imageCount === 4) return "double";
return "triple";
}
@@ -384,47 +384,64 @@
font-weight: 760; font-weight: 760;
} }
.mediaCover { .mediaGrid {
display: grid;
gap: 4px;
margin-top: 14px;
}
.mediaGridSingle {
width: min(72%, 280px);
grid-template-columns: minmax(0, 1fr);
}
.mediaGridDouble {
width: min(72%, 320px);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mediaGridTriple {
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.mediaGridItem {
position: relative; position: relative;
display: block; display: block;
width: 100%; width: 100%;
margin-top: 14px; aspect-ratio: 1;
padding: 0; padding: 0;
border: 0; border: 0;
overflow: hidden; overflow: hidden;
border-radius: clamp(20px, 5.185vw, 28px); border-radius: 6px;
background: #f3eef0; background: #f3eef0;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
text-align: inherit; transition: filter 0.16s ease, transform 0.16s ease;
box-shadow: 0 14px 32px rgba(131, 72, 85, 0.14);
} }
.momentCoverImage { .mediaGridSingle .mediaGridItem {
display: block;
width: 100%;
height: auto;
aspect-ratio: 4 / 5; aspect-ratio: 4 / 5;
object-fit: cover;
} }
.mediaCountBadge { .mediaGridImage {
object-fit: cover;
user-select: none;
}
.mediaGridOverflow {
position: absolute; position: absolute;
top: 12px; inset: 0;
right: 12px; z-index: 1;
display: inline-flex; display: flex;
min-height: 30px;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 0 11px; background: rgba(24, 18, 21, 0.56);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 999px;
background: rgba(28, 19, 23, 0.72);
color: #ffffff; color: #ffffff;
font-size: 12px; font-size: clamp(22px, 6.296vw, 34px);
font-weight: 800; font-weight: 760;
box-shadow: 0 8px 18px rgba(28, 19, 23, 0.18); letter-spacing: -0.02em;
backdrop-filter: blur(12px); text-shadow: 0 2px 12px rgba(0, 0, 0, 0.34);
} }
.emptyMediaCover { .emptyMediaCover {
@@ -474,12 +491,37 @@
overflow: hidden; overflow: hidden;
background: #090909; background: #090909;
color: #ffffff; color: #ffffff;
touch-action: pan-y; overscroll-behavior: contain;
} }
.galleryImageFrame { .galleryViewport {
position: absolute; position: absolute;
inset: 0; inset: 0;
overflow: hidden;
cursor: grab;
touch-action: pan-y;
user-select: none;
}
.galleryViewport[data-dragging="true"] {
cursor: grabbing;
}
.gallerySlide {
position: absolute;
inset: 0;
pointer-events: none;
transform: translate3d(
calc(var(--gallery-slide-offset, 0%) + var(--gallery-drag-offset, 0px)),
0,
0
);
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.galleryViewport[data-dragging="true"] .gallerySlide {
transition: none;
} }
.galleryImage { .galleryImage {
@@ -630,7 +672,7 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.mediaCover:hover { .mediaGridItem:hover {
filter: brightness(1.02); filter: brightness(1.02);
} }
@@ -644,7 +686,7 @@
transform: translateY(1px) scale(0.99); transform: translateY(1px) scale(0.99);
} }
.mediaCover:active { .mediaGridItem:active {
transform: scale(0.995); transform: scale(0.995);
} }
@@ -658,7 +700,7 @@
.primaryCta:focus-visible, .primaryCta:focus-visible,
.lockedPreview:focus-visible, .lockedPreview:focus-visible,
.mediaCover:focus-visible, .mediaGridItem:focus-visible,
.galleryClose:focus-visible, .galleryClose:focus-visible,
.galleryNav:focus-visible, .galleryNav:focus-visible,
.dialogPrimary:focus-visible, .dialogPrimary:focus-visible,
@@ -690,3 +732,9 @@
transform: scale(1.01); transform: scale(1.01);
} }
} }
@media (prefers-reduced-motion: reduce) {
.gallerySlide {
transition: none;
}
}
+23 -6
View File
@@ -36,6 +36,7 @@ import {
buildPrivateRoomWithoutGalleryUrl, buildPrivateRoomWithoutGalleryUrl,
getPrivateAlbumGalleryState, getPrivateAlbumGalleryState,
} from "./private-album-gallery-url"; } from "./private-album-gallery-url";
import { findPrivateAlbumDisplayImage } from "./private-album-images";
export function PrivateRoomScreen() { export function PrivateRoomScreen() {
const router = useRouter(); const router = useRouter();
@@ -87,14 +88,23 @@ export function PrivateRoomScreen() {
: null, : null,
[galleryState, state.items], [galleryState, state.items],
); );
const galleryImage = useMemo(
() =>
galleryAlbum && galleryState
? findPrivateAlbumDisplayImage(
galleryAlbum,
galleryState.imageIndex,
)
: null,
[galleryAlbum, galleryState],
);
useEffect(() => { useEffect(() => {
if (!galleryState || state.isLoading) return; if (!galleryState || state.isLoading) return;
const image = galleryAlbum?.images[galleryState.imageIndex];
if ( if (
!galleryAlbum || !galleryAlbum ||
isPrivateAlbumLocked(galleryAlbum) || isPrivateAlbumLocked(galleryAlbum) ||
!image?.url !galleryImage
) { ) {
router.replace( router.replace(
buildPrivateRoomWithoutGalleryUrl( buildPrivateRoomWithoutGalleryUrl(
@@ -107,6 +117,7 @@ export function PrivateRoomScreen() {
}, [ }, [
characterRoutes.privateRoom, characterRoutes.privateRoom,
galleryAlbum, galleryAlbum,
galleryImage,
galleryState, galleryState,
router, router,
searchParams, searchParams,
@@ -128,10 +139,14 @@ export function PrivateRoomScreen() {
}); });
}; };
const handleOpenGallery = (albumId: string) => { const handleOpenGallery = (albumId: string, imageIndex: number) => {
openedGalleryInPageRef.current = true; openedGalleryInPageRef.current = true;
router.push( router.push(
buildPrivateAlbumGalleryUrl(albumId, 0, characterRoutes.privateRoom), buildPrivateAlbumGalleryUrl(
albumId,
imageIndex,
characterRoutes.privateRoom,
),
{ scroll: false }, { scroll: false },
); );
}; };
@@ -247,7 +262,9 @@ export function PrivateRoomScreen() {
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
index={index} index={index}
isUnlocking={state.unlockingAlbumId === album.albumId} isUnlocking={state.unlockingAlbumId === album.albumId}
onOpenGallery={() => handleOpenGallery(album.albumId)} onOpenGallery={(imageIndex) =>
handleOpenGallery(album.albumId, imageIndex)
}
onUnlock={() => onUnlock={() =>
dispatch({ dispatch({
type: "PrivateRoomUnlockRequested", type: "PrivateRoomUnlockRequested",
@@ -285,7 +302,7 @@ export function PrivateRoomScreen() {
{galleryAlbum && {galleryAlbum &&
galleryState && galleryState &&
!isPrivateAlbumLocked(galleryAlbum) && !isPrivateAlbumLocked(galleryAlbum) &&
galleryAlbum.images[galleryState.imageIndex]?.url ? ( galleryImage ? (
<PrivateAlbumGallery <PrivateAlbumGallery
album={galleryAlbum} album={galleryAlbum}
imageIndex={galleryState.imageIndex} imageIndex={galleryState.imageIndex}
@@ -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 { SidebarScreen } from "../sidebar-screen"; import { ProfileScreen } from "../profile-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-sidebar-user-bootstrap", () => ({ vi.mock("../use-profile-user-bootstrap", () => ({
useSidebarUserBootstrap: vi.fn(), useProfileUserBootstrap: vi.fn(),
})); }));
describe("SidebarScreen", () => { describe("ProfileScreen", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
let root: Root; let root: Root;
@@ -83,12 +83,12 @@ describe("SidebarScreen", () => {
it("renders without CharacterProvider and preserves the source character", () => { it("renders without CharacterProvider and preserves the source character", () => {
act(() => { act(() => {
root.render(<SidebarScreen returnTo="/characters/maya/chat" />); root.render(<ProfileScreen returnTo="/characters/maya/chat" />);
}); });
expect( expect(
container.querySelector<HTMLAnchorElement>( container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="sidebar.back_to_chat"]', '[data-analytics-key="profile.back_to_chat"]',
)?.getAttribute("href"), )?.getAttribute("href"),
).toBe("/characters/maya/chat"); ).toBe("/characters/maya/chat");
@@ -107,7 +107,7 @@ describe("SidebarScreen", () => {
expect.objectContaining({ expect.objectContaining({
type: "topup", type: "topup",
sourceCharacterSlug: "maya", sourceCharacterSlug: "maya",
returnTo: "sidebar", returnTo: "profile",
}), }),
); );
}); });
@@ -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 {
getSidebarViewModel, getProfileViewModel,
getSidebarWalletView, getProfileWalletView,
normalizeSidebarCount, normalizeProfileCount,
} from "../sidebar-view-model"; } from "../profile-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("getSidebarViewModel", () => { describe("getProfileViewModel", () => {
it("derives guest state for logged-out users", () => { it("derives guest state for logged-out users", () => {
const view = getSidebarViewModel({ const view = getProfileViewModel({
loginStatus: "notLoggedIn", loginStatus: "notLoggedIn",
user: { user: {
currentUser: null, currentUser: null,
@@ -37,7 +37,7 @@ describe("getSidebarViewModel", () => {
}); });
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 = getSidebarViewModel({ const view = getProfileViewModel({
loginStatus: "email", loginStatus: "email",
user: { user: {
currentUser: baseUser, currentUser: baseUser,
@@ -54,7 +54,7 @@ describe("getSidebarViewModel", () => {
}); });
it("derives VIP state and hides VIP activation for VIP users", () => { it("derives VIP state and hides VIP activation for VIP users", () => {
const view = getSidebarViewModel({ const view = getProfileViewModel({
loginStatus: "email", loginStatus: "email",
user: { user: {
currentUser: { ...baseUser, isVip: true }, currentUser: { ...baseUser, isVip: true },
@@ -67,8 +67,8 @@ describe("getSidebarViewModel", () => {
expect(view.canActivateVip).toBe(false); expect(view.canActivateVip).toBe(false);
}); });
it("uses fallback name and marks logged-in users without profile as initializing", () => { it("keeps Guest users in the signed-out profile state", () => {
const view = getSidebarViewModel({ const view = getProfileViewModel({
loginStatus: "guest", loginStatus: "guest",
user: { user: {
currentUser: null, currentUser: null,
@@ -77,15 +77,15 @@ describe("getSidebarViewModel", () => {
}, },
}); });
expect(view.state).toBe("member"); expect(view.state).toBe("guest");
expect(view.name).toBe("User name"); expect(view.name).toBe("User name");
expect(view.isInitializingUser).toBe(true); expect(view.isInitializingUser).toBe(false);
}); });
}); });
describe("getSidebarWalletView", () => { describe("getProfileWalletView", () => {
it("normalizes missing, negative, and fractional wallet values", () => { it("normalizes missing, negative, and fractional wallet values", () => {
const view = getSidebarWalletView({ const view = getProfileWalletView({
currentUser: { currentUser: {
...baseUser, ...baseUser,
dailyFreeChatLimit: -1, dailyFreeChatLimit: -1,
@@ -107,7 +107,7 @@ describe("getSidebarWalletView", () => {
}); });
it("normalizes undefined and null counts to zero", () => { it("normalizes undefined and null counts to zero", () => {
expect(normalizeSidebarCount(undefined)).toBe(0); expect(normalizeProfileCount(undefined)).toBe(0);
expect(normalizeSidebarCount(null)).toBe(0); expect(normalizeProfileCount(null)).toBe(0);
}); });
}); });
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { UserHeader } from "../user-header"; import { UserHeader } from "../user-header";
describe("sidebar Tailwind components", () => { describe("profile 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 "./sidebar-wallet-card"; export * from "./profile-wallet-card";
export * from "./user-header"; export * from "./user-header";
@@ -2,9 +2,9 @@
import { FaCoins } from "react-icons/fa6"; import { FaCoins } from "react-icons/fa6";
import styles from "./sidebar-wallet-card.module.css"; import styles from "./profile-wallet-card.module.css";
export interface SidebarWalletCardProps { export interface ProfileWalletCardProps {
creditBalance: number; creditBalance: number;
dailyFreeChatLimit: number; dailyFreeChatLimit: number;
dailyFreeChatRemaining: number; dailyFreeChatRemaining: number;
@@ -18,7 +18,7 @@ export interface SidebarWalletCardProps {
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 SidebarWalletCard({ export function ProfileWalletCard({
creditBalance, creditBalance,
dailyFreeChatLimit, dailyFreeChatLimit,
dailyFreeChatRemaining, dailyFreeChatRemaining,
@@ -27,7 +27,7 @@ export function SidebarWalletCard({
onActivateVip, onActivateVip,
onTopUp, onTopUp,
onRulesClick, onRulesClick,
}: SidebarWalletCardProps) { }: ProfileWalletCardProps) {
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 SidebarWalletCard({
<button <button
type="button" type="button"
data-analytics-key="sidebar.coins_rules" data-analytics-key="profile.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 SidebarWalletCard({
{onActivateVip ? ( {onActivateVip ? (
<button <button
type="button" type="button"
data-analytics-key="sidebar.activate_vip" data-analytics-key="profile.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 SidebarWalletCard({
) : null} ) : null}
<button <button
type="button" type="button"
data-analytics-key="sidebar.topup" data-analytics-key="profile.topup"
data-analytics-label="Top up credits" data-analytics-label="Top up credits"
className={styles.topUpButton} className={styles.topUpButton}
onClick={onTopUp} onClick={onTopUp}
@@ -3,11 +3,11 @@
import Image from "next/image"; import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components"; import { UserMessageAvatar } from "@/app/_components";
import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model"; import type { ProfileUserState } from "@/app/profile/profile-view-model";
export interface UserHeaderProps { export interface UserHeaderProps {
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */ /** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
state: SidebarUserState; state: ProfileUserState;
/** 用户名(无 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-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white" /> <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 min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)"> <div className="flex min-w-0 flex-auto flex-col gap-(--profile-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-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white" className="flex size-(--profile-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white"
size="var(--sidebar-avatar-size, 56px)" size="var(--profile-avatar-size, 56px)"
/> />
<div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)"> <div className="flex min-w-0 flex-auto flex-col gap-(--profile-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/sidebar/ic_user_vip.png" src="/images/profile/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="sidebar.login" data-analytics-key="profile.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 { SidebarScreen } from "./sidebar-screen"; import { ProfileScreen } from "./profile-screen";
export default async function SidebarPage({ export default async function ProfilePage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<RouteSearchParams>; searchParams: Promise<RouteSearchParams>;
}) { }) {
const query = await searchParams; const query = await searchParams;
return <SidebarScreen returnTo={query.returnTo} />; return <ProfileScreen returnTo={query.returnTo} />;
} }
@@ -9,22 +9,23 @@ 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 { SidebarWalletCard, UserHeader } from "./components"; import { ProfileWalletCard, UserHeader } from "./components";
import { getSidebarViewModel } from "./sidebar-view-model"; import { getProfileViewModel } from "./profile-view-model";
import { usePwaInstallEntry } from "./use-pwa-install-entry"; import { usePwaInstallEntry } from "./use-pwa-install-entry";
import { useSidebarUserBootstrap } from "./use-sidebar-user-bootstrap"; import { useProfileUserBootstrap } from "./use-profile-user-bootstrap";
import styles from "./components/sidebar-screen.module.css"; import styles from "./components/profile-screen.module.css";
export interface SidebarScreenProps { export interface ProfileScreenProps {
returnTo?: GlobalReturnToValue; returnTo?: GlobalReturnToValue;
} }
export function SidebarScreen({ returnTo }: SidebarScreenProps) { export function ProfileScreen({ returnTo }: ProfileScreenProps) {
const navigator = useGlobalAppNavigator(); const navigator = useGlobalAppNavigator();
const navigation = resolveGlobalRouteContext(returnTo); const navigation = resolveGlobalRouteContext(returnTo);
const user = useUserState(); const user = useUserState();
@@ -33,16 +34,17 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const pwaInstallEntry = usePwaInstallEntry(); const pwaInstallEntry = usePwaInstallEntry();
useSidebarUserBootstrap({ auth, userDispatch }); useProfileUserBootstrap({ 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 = getSidebarViewModel({ const view = getProfileViewModel({
loginStatus: auth.loginStatus, loginStatus: auth.loginStatus,
user, user,
}); });
@@ -57,7 +59,7 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
<BackButton <BackButton
href={navigation.chatUrl} href={navigation.chatUrl}
variant="soft" variant="soft"
analyticsKey="sidebar.back_to_chat" analyticsKey="profile.back_to_chat"
/> />
</div> </div>
@@ -67,12 +69,12 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
name={view.name} name={view.name}
avatarUrl={view.avatarUrl} avatarUrl={view.avatarUrl}
isLoading={view.isInitializingUser} isLoading={view.isInitializingUser}
onLoginClick={() => navigator.openAuth(navigation.sidebarUrl)} onLoginClick={() => navigator.openAuth(navigation.profileUrl)}
/> />
</section> </section>
<section className={`${styles.cardSlot} ${styles.revealTwo}`}> <section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<SidebarWalletCard <ProfileWalletCard
creditBalance={view.wallet.creditBalance} creditBalance={view.wallet.creditBalance}
dailyFreeChatLimit={view.wallet.dailyFreeChatLimit} dailyFreeChatLimit={view.wallet.dailyFreeChatLimit}
dailyFreeChatRemaining={view.wallet.dailyFreeChatRemaining} dailyFreeChatRemaining={view.wallet.dailyFreeChatRemaining}
@@ -84,9 +86,9 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
navigator.openSubscription({ navigator.openSubscription({
type: "vip", type: "vip",
sourceCharacterSlug: navigation.characterSlug, sourceCharacterSlug: navigation.characterSlug,
returnTo: "sidebar", returnTo: "profile",
analytics: { analytics: {
entryPoint: "sidebar", entryPoint: "profile",
triggerReason: "vip_cta", triggerReason: "vip_cta",
}, },
}) })
@@ -96,10 +98,10 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
navigator.openSubscription({ navigator.openSubscription({
type: "topup", type: "topup",
sourceCharacterSlug: navigation.characterSlug, sourceCharacterSlug: navigation.characterSlug,
returnTo: "sidebar", returnTo: "profile",
analytics: { analytics: {
entryPoint: "sidebar", entryPoint: "profile",
triggerReason: "sidebar_recharge", triggerReason: "profile_recharge",
}, },
}) })
} }
@@ -113,7 +115,7 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
<div className={styles.settingsActions}> <div className={styles.settingsActions}>
<button <button
type="button" type="button"
data-analytics-key="sidebar.open_feedback" data-analytics-key="profile.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)}
@@ -155,7 +157,7 @@ export function SidebarScreen({ returnTo }: SidebarScreenProps) {
<button <button
type="button" type="button"
data-analytics-key="sidebar.logout" data-analytics-key="profile.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 SidebarUserState = "guest" | "member" | "vip"; export type ProfileUserState = "guest" | "member" | "vip";
export const SIDEBAR_FALLBACK_USERNAME = "User name"; export const PROFILE_FALLBACK_USERNAME = "User name";
export interface SidebarUserInput { export interface ProfileUserInput {
currentUser: UserView | null; currentUser: UserView | null;
avatarUrl: string | null; avatarUrl: string | null;
creditBalance?: number | null; creditBalance?: number | null;
} }
export interface SidebarWalletView { export interface ProfileWalletView {
creditBalance: number; creditBalance: number;
dailyFreeChatLimit: number; dailyFreeChatLimit: number;
dailyFreeChatRemaining: number; dailyFreeChatRemaining: number;
@@ -19,65 +19,67 @@ export interface SidebarWalletView {
dailyFreePrivateRemaining: number; dailyFreePrivateRemaining: number;
} }
export interface SidebarViewModel { export interface ProfileViewModel {
state: SidebarUserState; state: ProfileUserState;
name: string; name: string;
avatarUrl: string | null; avatarUrl: string | null;
isInitializingUser: boolean; isInitializingUser: boolean;
wallet: SidebarWalletView; wallet: ProfileWalletView;
canActivateVip: boolean; canActivateVip: boolean;
canShowSettings: boolean; canShowSettings: boolean;
} }
export function getSidebarViewModel({ export function getProfileViewModel({
loginStatus, loginStatus,
user, user,
}: { }: {
loginStatus: LoginStatus; loginStatus: LoginStatus;
user: SidebarUserInput; user: ProfileUserInput;
}): SidebarViewModel { }): ProfileViewModel {
const state = getSidebarUserState(loginStatus, user.currentUser); const state = getProfileUserState(loginStatus, user.currentUser);
return { return {
state, state,
name: user.currentUser?.username ?? SIDEBAR_FALLBACK_USERNAME, name: user.currentUser?.username ?? PROFILE_FALLBACK_USERNAME,
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
isInitializingUser: state !== "guest" && user.currentUser == null, isInitializingUser: state !== "guest" && user.currentUser == null,
wallet: getSidebarWalletView(user), wallet: getProfileWalletView(user),
canActivateVip: state !== "vip", canActivateVip: state !== "vip",
canShowSettings: state !== "guest", canShowSettings: state !== "guest",
}; };
} }
export function getSidebarUserState( export function getProfileUserState(
loginStatus: LoginStatus, loginStatus: LoginStatus,
currentUser: UserView | null, currentUser: UserView | null,
): SidebarUserState { ): ProfileUserState {
if (loginStatus === "notLoggedIn") return "guest"; if (loginStatus === "notLoggedIn" || loginStatus === "guest") {
return "guest";
}
return currentUser?.isVip === true ? "vip" : "member"; return currentUser?.isVip === true ? "vip" : "member";
} }
export function getSidebarWalletView( export function getProfileWalletView(
user: SidebarUserInput, user: ProfileUserInput,
): SidebarWalletView { ): ProfileWalletView {
const currentUser = user.currentUser; const currentUser = user.currentUser;
return { return {
creditBalance: normalizeSidebarCount(user.creditBalance), creditBalance: normalizeProfileCount(user.creditBalance),
dailyFreeChatLimit: normalizeSidebarCount(currentUser?.dailyFreeChatLimit), dailyFreeChatLimit: normalizeProfileCount(currentUser?.dailyFreeChatLimit),
dailyFreeChatRemaining: normalizeSidebarCount( dailyFreeChatRemaining: normalizeProfileCount(
currentUser?.dailyFreeChatRemaining, currentUser?.dailyFreeChatRemaining,
), ),
dailyFreePrivateLimit: normalizeSidebarCount( dailyFreePrivateLimit: normalizeProfileCount(
currentUser?.dailyFreePrivateLimit, currentUser?.dailyFreePrivateLimit,
), ),
dailyFreePrivateRemaining: normalizeSidebarCount( dailyFreePrivateRemaining: normalizeProfileCount(
currentUser?.dailyFreePrivateRemaining, currentUser?.dailyFreePrivateRemaining,
), ),
}; };
} }
export function normalizeSidebarCount(value: number | null | undefined): number { export function normalizeProfileCount(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0; if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value)); return Math.max(0, Math.trunc(value));
} }
@@ -4,17 +4,17 @@ import { type Dispatch, useEffect } from "react";
import type { LoginStatus } from "@/data/schemas/auth"; import type { LoginStatus } from "@/data/schemas/auth";
interface SidebarAuthBootstrapState { interface ProfileAuthBootstrapState {
hasInitialized: boolean; hasInitialized: boolean;
isLoading: boolean; isLoading: boolean;
loginStatus: LoginStatus; loginStatus: LoginStatus;
} }
export function useSidebarUserBootstrap({ export function useProfileUserBootstrap({
auth, auth,
userDispatch, userDispatch,
}: { }: {
auth: SidebarAuthBootstrapState; auth: ProfileAuthBootstrapState;
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-sidebar-background"); expect(html).toContain("bg-splash-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-sidebar-background"> <div className="absolute inset-0 z-0 overflow-hidden bg-splash-background">
<Image <Image
src={src} src={src}
alt="" alt=""
+1 -1
View File
@@ -50,7 +50,7 @@ export function SplashScreen() {
}, []); }, []);
return ( return (
<MobileShell background="var(--color-sidebar-background)"> <MobileShell background="var(--color-splash-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: "sidebar", returnTo: "profile",
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: "sidebar", returnTo: "profile",
sourceCharacterSlug: "maya", sourceCharacterSlug: "maya",
subscriptionType: "topup", subscriptionType: "topup",
}), }),
@@ -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", "sidebar"]).optional(), returnTo: z.enum(["chat", "private-room", "profile"]).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: "sidebar", entryPoint: "profile",
triggerReason: null, triggerReason: null,
subscriptionType: "vip", subscriptionType: "vip",
}), }),
).toEqual({ entryPoint: "sidebar", triggerReason: "vip_cta" }); ).toEqual({ entryPoint: "profile", 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"
| "sidebar_recharge" | "profile_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"
| "sidebar" | "profile"
| "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",
"sidebar", "profile",
"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",
"sidebar_recharge", "profile_recharge",
"vip_cta", "vip_cta",
"ad_landing", "ad_landing",
"manual_recharge", "manual_recharge",
@@ -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: "sidebar" })).toBe(ROUTES.chat); expect(resolveExternalEntryTarget({ target: "profile" })).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 sidebar by default", async () => { it("falls back to profile by default", async () => {
consumePendingChatImageReturnMock.mockResolvedValue(null); consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue(null); peekPendingChatUnlockMock.mockResolvedValue(null);
expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar); expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.profile);
await expect(consumeSubscriptionExitUrl(null)).resolves.toBe( await expect(consumeSubscriptionExitUrl(null)).resolves.toBe(
ROUTES.sidebar, ROUTES.profile,
); );
}); });
it("returns to global sidebar with the source character", async () => { it("returns to global profile with the source character", async () => {
const expectedUrl = buildGlobalPageUrl( const expectedUrl = buildGlobalPageUrl(
ROUTES.sidebar, ROUTES.profile,
getCharacterRoutes("maya").chat, getCharacterRoutes("maya").chat,
); );
expect(getSubscriptionFallbackExitUrl("sidebar", "maya")).toBe( expect(getSubscriptionFallbackExitUrl("profile", "maya")).toBe(
expectedUrl, expectedUrl,
); );
await expect( await expect(
consumeSubscriptionExitUrl("sidebar", "maya"), consumeSubscriptionExitUrl("profile", "maya"),
).resolves.toBe(expectedUrl); ).resolves.toBe(expectedUrl);
await expect( await expect(
resolveSubscriptionSuccessExitUrl("sidebar", "maya"), resolveSubscriptionSuccessExitUrl("profile", "maya"),
).resolves.toBe(expectedUrl); ).resolves.toBe(expectedUrl);
expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled(); expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled();
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled(); expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
+5 -5
View File
@@ -48,10 +48,10 @@ export function getSubscriptionFallbackExitUrl(
const routes = getCharacterRoutes(characterSlug); const routes = getCharacterRoutes(characterSlug);
if (returnTo === "chat") return routes.chat; if (returnTo === "chat") return routes.chat;
if (returnTo === "private-room") return routes.privateRoom; if (returnTo === "private-room") return routes.privateRoom;
if (returnTo === "sidebar") { if (returnTo === "profile") {
return buildGlobalPageUrl(ROUTES.sidebar, routes.chat); return buildGlobalPageUrl(ROUTES.profile, routes.chat);
} }
return ROUTES.sidebar; return ROUTES.profile;
} }
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 === "sidebar") { if (returnTo === "profile") {
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 === "sidebar") { if (returnTo === "profile") {
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("sidebar")).toBe("sidebar"); expect(parseSubscriptionReturnTo("profile")).toBe("profile");
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 sidebar returns", async () => { it("stores and rebuilds global profile returns", async () => {
await clearPendingPaymentOrder(); await clearPendingPaymentOrder();
const saveResult = await savePendingEzpayOrder({ const saveResult = await savePendingEzpayOrder({
orderId: "order-sidebar", orderId: "order-profile",
returnTo: "sidebar", returnTo: "profile",
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-sidebar", orderId: "order-profile",
returnTo: "sidebar", returnTo: "profile",
characterSlug: "maya", characterSlug: "maya",
}, },
}); });
expect( expect(
buildPendingPaymentSubscriptionUrl({ buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay", payChannel: "ezpay",
returnTo: "sidebar", returnTo: "profile",
subscriptionType: "topup", subscriptionType: "topup",
characterSlug: "maya", characterSlug: "maya",
}), }),
).toBe( ).toBe(
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=sidebar&character=maya", "/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=profile&character=maya",
); );
await clearPendingPaymentOrder(); await clearPendingPaymentOrder();
}); });
+1 -1
View File
@@ -29,7 +29,7 @@ export function parseSubscriptionReturnTo(
if ( if (
returnTo === "chat" || returnTo === "chat" ||
returnTo === "private-room" || returnTo === "private-room" ||
returnTo === "sidebar" returnTo === "profile"
) { ) {
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.sidebarUrl).toBe( expect(context.profileUrl).toBe(
"/sidebar?returnTo=%2Fcharacters%2Fmaya%2Fchat", "/profile?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.sidebar, "https://example.com")).toBe( expect(buildGlobalPageUrl(ROUTES.profile, "https://example.com")).toBe(
"/sidebar?returnTo=%2Fcharacters%2Felio%2Fchat", "/profile?returnTo=%2Fcharacters%2Felio%2Fchat",
); );
}); });
}); });
@@ -5,11 +5,6 @@ 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",
@@ -0,0 +1,26 @@
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",
);
});
});
@@ -75,14 +75,22 @@ describe("navigation resolver", () => {
).toBeNull(); ).toBeNull();
}); });
it("redirects not logged in session routes except chat to splash", () => { it.each(["notLoggedIn", "guest"] as const)(
expect( "redirects %s users from profile to auth with the return context",
resolveRouteGuardRedirect({ (loginStatus) => {
loginStatus: "notLoggedIn", expect(
pathname: ROUTES.sidebar, resolveRouteGuardRedirect({
}), loginStatus,
).toBe(ROUTES.splash); pathname: ROUTES.profile,
}); searchParams: "returnTo=%2Fcharacters%2Fmaya%2Fchat",
}),
).toBe(
ROUTE_BUILDERS.authWithRedirect(
"/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat",
),
);
},
);
it("redirects non-real users on subscription routes to auth", () => { it("redirects non-real users on subscription routes to auth", () => {
expect( expect(
+2 -2
View File
@@ -11,7 +11,7 @@ describe("route meta", () => {
expect(getRouteAccess(ROUTES.privateRoom)).toBe("guestEntry"); expect(getRouteAccess(ROUTES.privateRoom)).toBe("guestEntry");
expect(getRouteAccess(ROUTES.tip)).toBe("public"); expect(getRouteAccess(ROUTES.tip)).toBe("public");
expect(getRouteAccess(ROUTES.externalEntry)).toBe("public"); expect(getRouteAccess(ROUTES.externalEntry)).toBe("public");
expect(getRouteAccess(ROUTES.sidebar)).toBe("session"); expect(getRouteAccess(ROUTES.profile)).toBe("realUser");
expect(getRouteAccess(ROUTES.feedback)).toBe("session"); expect(getRouteAccess(ROUTES.feedback)).toBe("session");
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser"); expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public"); expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
@@ -36,7 +36,7 @@ describe("route meta", () => {
}); });
it("does not classify removed character-scoped global pages", () => { it("does not classify removed character-scoped global pages", () => {
expect(getRouteAccess("/characters/maya/sidebar")).toBe("public"); expect(getRouteAccess("/characters/maya/profile")).toBe("public");
expect(getRouteAccess("/characters/maya/feedback")).toBe("public"); expect(getRouteAccess("/characters/maya/feedback")).toBe("public");
expect(getRouteAccess("/characters/maya/coins-rules")).toBe("public"); expect(getRouteAccess("/characters/maya/coins-rules")).toBe("public");
}); });
+7
View File
@@ -9,6 +9,7 @@ import {
useAuthSelector, useAuthSelector,
} from "@/stores/auth/auth-context"; } from "@/stores/auth/auth-context";
import { consumePendingLogoutNavigation } from "./logout-navigation";
import { resolveRouteGuardRedirect } from "./navigation-resolver"; import { resolveRouteGuardRedirect } from "./navigation-resolver";
export function AppNavigationGuard() { export function AppNavigationGuard() {
@@ -26,6 +27,12 @@ export function AppNavigationGuard() {
useEffect(() => { useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return; if (!authState.hasInitialized || authState.isLoading) return;
const logoutRedirect = consumePendingLogoutNavigation();
if (logoutRedirect) {
router.replace(logoutRedirect);
return;
}
const redirectTo = resolveRouteGuardRedirect({ const redirectTo = resolveRouteGuardRedirect({
loginStatus: authState.loginStatus, loginStatus: authState.loginStatus,
pathname, pathname,
+3 -3
View File
@@ -9,7 +9,7 @@ import { getCharacterRoutes, ROUTES } from "./routes";
export const GLOBAL_RETURN_TO_PARAM = "returnTo"; export const GLOBAL_RETURN_TO_PARAM = "returnTo";
export type GlobalPageRoute = export type GlobalPageRoute =
| typeof ROUTES.sidebar | typeof ROUTES.profile
| typeof ROUTES.feedback | typeof ROUTES.feedback
| typeof ROUTES.coinsRules; | typeof ROUTES.coinsRules;
@@ -17,7 +17,7 @@ export interface GlobalRouteContext {
readonly characterSlug: CharacterProfile["slug"]; readonly characterSlug: CharacterProfile["slug"];
readonly chatUrl: string; readonly chatUrl: string;
readonly splashUrl: string; readonly splashUrl: string;
readonly sidebarUrl: string; readonly profileUrl: string;
readonly feedbackUrl: string; readonly feedbackUrl: string;
readonly coinsRulesUrl: string; readonly coinsRulesUrl: string;
} }
@@ -39,7 +39,7 @@ export function resolveGlobalRouteContext(
characterSlug: character.slug, characterSlug: character.slug,
chatUrl, chatUrl,
splashUrl: characterRoutes.splash, splashUrl: characterRoutes.splash,
sidebarUrl: buildGlobalPageUrl(ROUTES.sidebar, chatUrl), profileUrl: buildGlobalPageUrl(ROUTES.profile, chatUrl),
feedbackUrl: buildGlobalPageUrl(ROUTES.feedback, chatUrl), feedbackUrl: buildGlobalPageUrl(ROUTES.feedback, chatUrl),
coinsRulesUrl: buildGlobalPageUrl(ROUTES.coinsRules, chatUrl), coinsRulesUrl: buildGlobalPageUrl(ROUTES.coinsRules, chatUrl),
}); });
@@ -6,11 +6,6 @@ export interface LegacyGlobalRouteRedirect {
export const LEGACY_GLOBAL_ROUTE_REDIRECTS: readonly LegacyGlobalRouteRedirect[] = export const LEGACY_GLOBAL_ROUTE_REDIRECTS: readonly LegacyGlobalRouteRedirect[] =
Object.freeze([ Object.freeze([
{
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",
+11
View File
@@ -0,0 +1,11 @@
let pendingLogoutUrl: string | null = null;
export function setPendingLogoutNavigation(url: string): void {
pendingLogoutUrl = url;
}
export function consumePendingLogoutNavigation(): string | null {
const url = pendingLogoutUrl;
pendingLogoutUrl = null;
return url;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export type AppSubscriptionType = "vip" | "topup";
export type AppSubscriptionReturnTo = export type AppSubscriptionReturnTo =
| "chat" | "chat"
| "private-room" | "private-room"
| "sidebar" | "profile"
| null; | null;
export interface OpenSubscriptionInput { export interface OpenSubscriptionInput {
+1 -1
View File
@@ -15,7 +15,7 @@ const GLOBAL_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.privateRoom]: "guestEntry", [ROUTES.privateRoom]: "guestEntry",
[ROUTES.tip]: "public", [ROUTES.tip]: "public",
[ROUTES.externalEntry]: "public", [ROUTES.externalEntry]: "public",
[ROUTES.sidebar]: "session", [ROUTES.profile]: "realUser",
[ROUTES.feedback]: "session", [ROUTES.feedback]: "session",
[ROUTES.subscription]: "realUser", [ROUTES.subscription]: "realUser",
[ROUTES.coinsRules]: "public", [ROUTES.coinsRules]: "public",
+2 -2
View File
@@ -25,7 +25,7 @@ export const ROUTES = {
tip: "/tip", tip: "/tip",
externalEntry: "/external-entry", externalEntry: "/external-entry",
auth: "/auth", auth: "/auth",
sidebar: "/sidebar", profile: "/profile",
feedback: "/feedback", feedback: "/feedback",
subscription: "/subscription", subscription: "/subscription",
coinsRules: "/coins-rules", coinsRules: "/coins-rules",
@@ -114,7 +114,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
ROUTES.tip, ROUTES.tip,
ROUTES.externalEntry, ROUTES.externalEntry,
ROUTES.auth, ROUTES.auth,
ROUTES.sidebar, ROUTES.profile,
ROUTES.feedback, ROUTES.feedback,
ROUTES.coinsRules, ROUTES.coinsRules,
] as const; ] as const;
+3 -3
View File
@@ -31,8 +31,8 @@
--color-page-background: #ffffff; --color-page-background: #ffffff;
/* ==================== 扩展令牌(迁移自 Flutter UI ==================== */ /* ==================== 扩展令牌(迁移自 Flutter UI ==================== */
/* 侧边栏/Profile 深色背景 */ /* Splash 深色背景 */
--color-sidebar-background: #0d0b14; --color-splash-background: #0d0b14;
--color-dark-background: #0d0b14; --color-dark-background: #0d0b14;
--color-dark-gray: #2a2a2a; --color-dark-gray: #2a2a2a;
--color-bubble-background: #1a1625; --color-bubble-background: #1a1625;
@@ -91,7 +91,7 @@
--color-chevron-icon: #000000; --color-chevron-icon: #000000;
--color-destructive: #ff6b6b; --color-destructive: #ff6b6b;
/* ==================== Sidebar(浅色主题) ==================== */ /* ==================== Profile(浅色主题) ==================== */
/* 卡片纯白表面 */ /* 卡片纯白表面 */
--color-card-surface: #ffffff; --color-card-surface: #ffffff;
+8 -8
View File
@@ -54,14 +54,14 @@
--chat-avatar-size: clamp(38px, 7.963vw, 43px); --chat-avatar-size: clamp(38px, 7.963vw, 43px);
--chat-send-button-size: clamp(40px, 8.148vw, 44px); --chat-send-button-size: clamp(40px, 8.148vw, 44px);
/* Sidebar 专用响应式尺寸 */ /* Profile 专用响应式尺寸 */
--sidebar-user-padding-y: clamp(18px, 3.704vw, 20px); --profile-user-padding-y: clamp(18px, 3.704vw, 20px);
--sidebar-card-gap-y: clamp(6px, 1.481vw, 8px); --profile-card-gap-y: clamp(6px, 1.481vw, 8px);
--sidebar-card-padding-x: clamp(18px, 5.185vw, 28px); --profile-card-padding-x: clamp(18px, 5.185vw, 28px);
--sidebar-card-padding-y: clamp(16px, 3.333vw, 18px); --profile-card-padding-y: clamp(16px, 3.333vw, 18px);
--sidebar-card-padding-right: clamp(10px, 2.222vw, 12px); --profile-card-padding-right: clamp(10px, 2.222vw, 12px);
--sidebar-avatar-size: clamp(48px, 10.37vw, 56px); --profile-avatar-size: clamp(48px, 10.37vw, 56px);
--sidebar-voice-image-size: clamp(84px, 18.519vw, 100px); --profile-voice-image-size: clamp(84px, 18.519vw, 100px);
/* 响应式字体:按 540px 设计宽度换算,并给小屏设置可读下限。 */ /* 响应式字体:按 540px 设计宽度换算,并给小屏设置可读下限。 */
--font-responsive-md: clamp(14px, 2.963vw, 16px); --font-responsive-md: clamp(14px, 2.963vw, 16px);