Compare commits
6 Commits
68f9ad6fe6
...
738185f3ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 738185f3ac | |||
| 7f271ef2e0 | |||
| 9fff5b16fd | |||
| 9deb320cf6 | |||
| 6cd3a0c2d2 | |||
| fe954f7098 |
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 Actor,Top-up 通过路由级导航进入独立 Payment Provider。
|
- Private Room 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`。
|
||||||
@@ -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(?:\\?.*)?$`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
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 |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 468 KiB After Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
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'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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type AvatarInteractionProps =
|
||||||
|
| {
|
||||||
|
onClick?: undefined;
|
||||||
|
actionLabel?: never;
|
||||||
|
analyticsKey?: never;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
onClick: () => void;
|
||||||
|
actionLabel: string;
|
||||||
|
analyticsKey?: string;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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"',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
/**
|
/**
|
||||||
* ChatHeader 顶部栏
|
* ChatHeader 顶部栏
|
||||||
*
|
*
|
||||||
* 图标:lucide-react <Lock />(游客 banner)+ <Menu />(菜单按钮)
|
* Profile 优先展示用户头像;头像缺失时回退到 lucide-react <UserRound />。
|
||||||
* tree-shakable,currentColor 继承父 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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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=""
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||