Compare commits

...

3 Commits

Author SHA1 Message Date
admin f8b26d7578 feat(chat): add composer action menu
Docker Image / Build and Push Docker Image (push) Successful in 1m51s
2026-07-21 19:17:32 +08:00
admin e1fb53eba6 chore(favicon): 使用预发布环境的图标 2026-07-21 19:17:32 +08:00
admin 9a51acd636 feat(private-room): redesign locked album cards 2026-07-21 19:15:35 +08:00
19 changed files with 895 additions and 158 deletions
+1 -1
View File
@@ -216,7 +216,7 @@ Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image
- React key 使用稳定 `albumId` - React key 使用稳定 `albumId`
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length` - 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`
- 锁定相册继续只显示模糊封面和解锁入口 - 锁定相册显示模糊封面、角色头像、锁标识、图片数量和 `View collection` 入口;卡片不展示视频数量或积分价格,解锁价格只在确认 Dialog 中展示
- 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引; - 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引;
- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格; - 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格;
- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片; - 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 35 KiB

@@ -0,0 +1,174 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
}));
vi.mock("@/stores/chat/chat-context", () => ({
useChatDispatch: () => mocks.dispatch,
}));
vi.mock("@/providers/character-provider", () => ({
useActiveCharacter: () => ({ id: "maya-tan" }),
useActiveCharacterRoutes: () => ({
tip: "/characters/maya/tip",
}),
}));
vi.mock("../../hooks/use-chat-keyboard-avoidance", () => ({
useChatKeyboardAvoidance: () => undefined,
}));
vi.mock("../../hooks/use-chat-keyboard-diagnostics", () => ({
useChatKeyboardDiagnostics: () => undefined,
}));
import { ChatInputBar } from "../chat-input-bar";
describe("ChatInputBar", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.dispatch.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("opens the ordered action menu, blurs input, and links to Tip", () => {
renderBar();
const textarea = getTextarea();
act(() => textarea.focus());
expect(document.activeElement).toBe(textarea);
act(() => getButton("Open chat actions").click());
expect(document.activeElement).not.toBe(textarea);
const menu = container.querySelector('[aria-label="Chat actions"]');
expect(menu).not.toBeNull();
expect(
Array.from(menu?.children ?? []).map((item) => item.textContent?.trim()),
).toEqual(["Image", "Voice", "Tip"]);
expect(getButton("Close chat actions").getAttribute("aria-expanded")).toBe(
"true",
);
expect(
menu
?.querySelector<HTMLAnchorElement>('[data-analytics-key="chat.open_tip"]')
?.getAttribute("href"),
).toBe("/characters/maya/tip");
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
});
it("injects image and voice promotions without persisting UI state", () => {
renderBar();
act(() => getButton("Open chat actions").click());
act(() => getButton("Image").click());
const imageEvent = mocks.dispatch.mock.calls[0]?.[0];
expect(imageEvent).toMatchObject({
type: "ChatPromotionInjected",
promotion: {
characterId: "maya-tan",
promotionType: "image",
lockType: "image_paywall",
},
});
expect(imageEvent.promotion.clientLockId).toMatch(/^promotion_/);
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
act(() => getButton("Open chat actions").click());
act(() => getButton("Voice").click());
const voiceEvent = mocks.dispatch.mock.calls[1]?.[0];
expect(voiceEvent).toMatchObject({
type: "ChatPromotionInjected",
promotion: {
characterId: "maya-tan",
promotionType: "voice",
lockType: "voice_message",
},
});
expect(voiceEvent.promotion.clientLockId).not.toBe(
imageEvent.promotion.clientLockId,
);
});
it("switches from actions to Send for non-whitespace input", () => {
renderBar();
act(() => getButton("Open chat actions").click());
setTextareaValue(getTextarea(), "Hello Maya");
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
expect(getButton("Send message")).not.toBeNull();
act(() => getButton("Send message").click());
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "ChatSendMessage",
content: "Hello Maya",
});
expect(getTextarea().value).toBe("");
expect(document.activeElement).toBe(getTextarea());
setTextareaValue(getTextarea(), " ");
expect(getButton("Open chat actions")).not.toBeNull();
});
it("closes the menu on outside interaction and when disabled", () => {
renderBar();
act(() => getButton("Open chat actions").click());
act(() => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })));
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
act(() => getButton("Open chat actions").click());
renderBar(true);
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
expect(getButton("Open chat actions").disabled).toBe(true);
});
function renderBar(disabled = false): void {
act(() => root.render(<ChatInputBar disabled={disabled} />));
}
function getTextarea(): HTMLTextAreaElement {
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Missing chat textarea");
return textarea;
}
function getButton(label: string): HTMLButtonElement {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find(
(item) =>
item.getAttribute("aria-label") === label ||
item.textContent?.trim() === label,
);
if (!button) throw new Error(`Missing button: ${label}`);
return button;
}
});
function setTextareaValue(
textarea: HTMLTextAreaElement,
value: string,
): void {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
act(() => {
setter?.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
@@ -126,7 +126,10 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={false} disabled={false}
hasContent={true} hasContent={true}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -134,7 +137,21 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={false} disabled={false}
hasContent={false} hasContent={false}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined}
/>,
);
const openHtml = renderToStaticMarkup(
<ChatSendButton
disabled={false}
hasContent={false}
isMenuOpen={true}
menuId="chat-actions"
onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -142,7 +159,10 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={true} disabled={true}
hasContent={true} hasContent={true}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -150,12 +170,17 @@ describe("chat Tailwind components", () => {
expect(activeHtml).toContain('aria-label="Send message"'); expect(activeHtml).toContain('aria-label="Send message"');
expect(activeHtml).toContain('data-analytics-ignore="true"'); expect(activeHtml).toContain('data-analytics-ignore="true"');
expect(activeHtml).not.toContain("data-analytics-key"); expect(activeHtml).not.toContain("data-analytics-key");
expect(activeHtml).toContain("size-(--chat-send-button-size,40px)"); expect(activeHtml).toContain("size-(--chat-send-button-size,42px)");
expect(activeHtml).toContain( expect(activeHtml).toContain(
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
); );
expect(emptyHtml).toContain("bg-[#f8a8ce]"); expect(emptyHtml).toContain('aria-label="Open chat actions"');
expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]"); expect(emptyHtml).toContain('aria-expanded="false"');
expect(emptyHtml).toContain('aria-controls="chat-actions"');
expect(emptyHtml).toContain('data-analytics-key="chat.toggle_actions"');
expect(openHtml).toContain('aria-label="Close chat actions"');
expect(openHtml).toContain('aria-expanded="true"');
expect(openHtml).toContain("bg-[#38262d]");
expect(disabledHtml).toContain("disabled"); expect(disabledHtml).toContain("disabled");
}); });
@@ -0,0 +1,148 @@
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 10px);
z-index: 4;
display: grid;
width: min(100%, 348px);
box-sizing: border-box;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 9px;
border: 1px solid rgba(77, 48, 57, 0.09);
border-radius: 24px;
background: rgba(255, 255, 255, 0.95);
box-shadow:
0 22px 54px rgba(74, 45, 55, 0.16),
0 4px 14px rgba(74, 45, 55, 0.08);
backdrop-filter: blur(20px);
transform-origin: right bottom;
}
.action {
display: flex;
min-width: 0;
min-height: 76px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 6px;
border: 0;
border-radius: 18px;
background: #faf7f8;
color: #543d45;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 850;
line-height: 1;
text-decoration: none;
transition:
background 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.action:disabled,
.action[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.48;
}
.icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 14px;
}
.imageIcon {
background: #fff0f4;
color: #d94f7c;
}
.voiceIcon {
background: #fff4e8;
color: #d87343;
}
.tipIcon {
background: #f7f0e8;
color: #986a42;
}
.action:focus-visible {
outline: 3px solid rgba(246, 87, 160, 0.28);
outline-offset: 2px;
}
@media (hover: hover) {
.action:not(:disabled):not([aria-disabled="true"]):hover {
background: #fff0f5;
color: #b83d69;
transform: translateY(-1px);
}
}
@media (max-width: 350px) {
.menu {
gap: 6px;
padding: 7px;
border-radius: 21px;
}
.action {
min-height: 70px;
border-radius: 16px;
font-size: 11px;
}
.icon {
width: 35px;
height: 35px;
}
}
@media (prefers-reduced-motion: no-preference) {
.menu {
animation: menuReveal 0.22s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.action {
animation: actionReveal 0.25s ease both;
}
.action:nth-child(2) {
animation-delay: 35ms;
}
.action:nth-child(3) {
animation-delay: 70ms;
}
}
@keyframes menuReveal {
from {
opacity: 0;
transform: translateY(7px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes actionReveal {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -0,0 +1,72 @@
"use client";
import { ImagePlus, Mic2, Coffee } from "lucide-react";
import Link from "next/link";
import styles from "./chat-composer-action-menu.module.css";
export interface ChatComposerActionMenuProps {
id: string;
disabled?: boolean;
tipHref: string;
onImage: () => void;
onVoice: () => void;
onNavigate: () => void;
}
export function ChatComposerActionMenu({
id,
disabled = false,
tipHref,
onImage,
onVoice,
onNavigate,
}: ChatComposerActionMenuProps) {
return (
<div id={id} className={styles.menu} aria-label="Chat actions">
<button
type="button"
className={styles.action}
disabled={disabled}
data-analytics-key="chat.promotion_image"
onClick={onImage}
>
<span className={`${styles.icon} ${styles.imageIcon}`}>
<ImagePlus size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Image</span>
</button>
<button
type="button"
className={styles.action}
disabled={disabled}
data-analytics-key="chat.promotion_voice"
onClick={onVoice}
>
<span className={`${styles.icon} ${styles.voiceIcon}`}>
<Mic2 size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Voice</span>
</button>
<Link
href={tipHref}
className={styles.action}
aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
data-analytics-key="chat.open_tip"
onClick={(event) => {
if (disabled) {
event.preventDefault();
return;
}
onNavigate();
}}
>
<span className={`${styles.icon} ${styles.tipIcon}`}>
<Coffee size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Tip</span>
</Link>
</div>
);
}
@@ -1,10 +1,10 @@
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
/* Keyboard inset is a total bottom inset, not an addition to normal spacing. */ /* Keyboard inset is a total bottom inset, not an addition to normal spacing. */
.bar { .bar {
position: relative;
z-index: 5;
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 padding: clamp(7px, 1.852vw, 10px)
calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px)) calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px))
max( max(
calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)), calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)),
@@ -12,28 +12,43 @@
) )
calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px)); calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px));
background: transparent; background: transparent;
transition: padding-top 0.2s ease, padding-bottom 0.2s ease, transition: padding-bottom 0.2s ease;
background-color 0.2s ease, box-shadow 0.2s ease;
} }
.barFocused { .composer {
padding-top: var(--spacing-md, 12px); position: relative;
background: #feeff2; width: 100%;
box-shadow: 0 4px 12px var(--color-input-box-shadow, rgba(0, 0, 0, 0.1));
} }
/* 内层:白底 + 大圆角(Dart AppRadius.radius32+ focused 时 accent 边框 */
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm, 8px); gap: 6px;
padding: var(--spacing-sm, 8px); padding: 6px;
background: #fff; border: 1px solid rgba(94, 62, 73, 0.11);
border-radius: var(--radius-full, 999px); border-radius: var(--radius-full, 999px);
border: 1px solid transparent; background: rgba(255, 255, 255, 0.94);
transition: border-color 0.2s ease; box-shadow:
0 12px 30px rgba(75, 48, 57, 0.1),
0 2px 8px rgba(75, 48, 57, 0.05);
backdrop-filter: blur(18px);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
} }
.rowFocused { .rowFocused {
border-color: var(--color-accent, #f84d96); border-color: rgba(246, 87, 160, 0.62);
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 0 0 3px rgba(246, 87, 160, 0.1),
0 14px 34px rgba(92, 52, 67, 0.12);
}
@media (max-width: 350px) {
.bar {
padding-right: calc(12px + var(--app-safe-right, 0px));
padding-left: calc(12px + var(--app-safe-left, 0px));
}
} }
+95 -20
View File
@@ -1,17 +1,24 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPendingChatPromotion } from "@/lib/navigation/chat_unlock_session";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance"; import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics"; import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics";
import { ChatComposerActionMenu } from "./chat-composer-action-menu";
import { ChatInputTextField } from "./chat-input-text-field"; import { ChatInputTextField } from "./chat-input-text-field";
import { ChatSendButton } from "./chat-send-button"; import { ChatSendButton } from "./chat-send-button";
import styles from "./chat-input-bar.module.css"; import styles from "./chat-input-bar.module.css";
const log = new Logger("AppChatComponentsChatInputBar"); const log = new Logger("AppChatComponentsChatInputBar");
const POINTER_SEND_CLICK_DEDUPE_MS = 500; const POINTER_SEND_CLICK_DEDUPE_MS = 500;
const CHAT_ACTION_MENU_ID = "chat-composer-action-menu";
export interface ChatInputBarProps { export interface ChatInputBarProps {
disabled?: boolean; disabled?: boolean;
@@ -19,22 +26,57 @@ export interface ChatInputBarProps {
export function ChatInputBar({ disabled = false }: ChatInputBarProps) { export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const dispatch = useChatDispatch(); const dispatch = useChatDispatch();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
const [previousDisabled, setPreviousDisabled] = useState(disabled);
const barRef = useRef<HTMLDivElement>(null); const barRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const lastPointerSendAtRef = useRef(0); const lastPointerSendAtRef = useRef(0);
const hasContent = input.trim().length > 0; const hasContent = input.trim().length > 0;
if (disabled !== previousDisabled) {
setPreviousDisabled(disabled);
if (disabled && isActionMenuOpen) setIsActionMenuOpen(false);
}
useChatKeyboardAvoidance({ useChatKeyboardAvoidance({
active: isFocused, active: isFocused,
containerRef: barRef, containerRef: barRef,
}); });
useChatKeyboardDiagnostics({ containerRef: barRef }); useChatKeyboardDiagnostics({ containerRef: barRef });
useEffect(() => {
if (!isActionMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && barRef.current?.contains(target)) return;
setIsActionMenuOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsActionMenuOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isActionMenuOpen]);
const handleInputChange = (value: string) => { const handleInputChange = (value: string) => {
setInput(value); setInput(value);
if (value.trim().length > 0) setIsActionMenuOpen(false);
};
const handleFocusChange = (focused: boolean) => {
setIsFocused(focused);
if (focused) setIsActionMenuOpen(false);
}; };
const handleSend = (source: "click" | "keyboard" | "pointerdown") => { const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
@@ -62,6 +104,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
}); });
dispatch({ type: "ChatSendMessage", content: input }); dispatch({ type: "ChatSendMessage", content: input });
setInput(""); setInput("");
setIsActionMenuOpen(false);
textareaRef.current?.focus(); textareaRef.current?.focus();
}; };
@@ -70,26 +113,58 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
handleSend("pointerdown"); handleSend("pointerdown");
}; };
const handleMenuToggle = () => {
if (disabled || hasContent) return;
if (!isActionMenuOpen) textareaRef.current?.blur();
setIsActionMenuOpen((open) => !open);
};
const handlePromotion = (promotionType: "image" | "voice") => {
if (disabled) return;
dispatch({
type: "ChatPromotionInjected",
promotion: createPendingChatPromotion(
promotionType,
character.id,
),
});
setIsActionMenuOpen(false);
};
return ( return (
<div <div ref={barRef} className={styles.bar}>
ref={barRef} <div className={styles.composer}>
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`} {isActionMenuOpen ? (
> <ChatComposerActionMenu
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}> id={CHAT_ACTION_MENU_ID}
<ChatInputTextField disabled={disabled}
ref={textareaRef} tipHref={characterRoutes.tip}
value={input} onImage={() => handlePromotion("image")}
onChange={handleInputChange} onVoice={() => handlePromotion("voice")}
onSubmit={() => handleSend("keyboard")} onNavigate={() => setIsActionMenuOpen(false)}
onFocusChange={setIsFocused} />
disabled={disabled} ) : null}
/> <div
<ChatSendButton className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}
disabled={disabled} >
hasContent={hasContent} <ChatInputTextField
onClick={() => handleSend("click")} ref={textareaRef}
onPointerDownSend={handlePointerDownSend} value={input}
/> onChange={handleInputChange}
onSubmit={() => handleSend("keyboard")}
onFocusChange={handleFocusChange}
disabled={disabled}
/>
<ChatSendButton
disabled={disabled}
hasContent={hasContent}
isMenuOpen={isActionMenuOpen}
menuId={CHAT_ACTION_MENU_ID}
onClick={() => handleSend("click")}
onMenuToggle={handleMenuToggle}
onPointerDownSend={handlePointerDownSend}
/>
</div>
</div> </div>
</div> </div>
); );
@@ -59,7 +59,7 @@ export const ChatInputTextField = forwardRef<
}; };
return ( return (
<div className="flex min-w-0 flex-auto items-center rounded-full bg-white px-(--spacing-lg,16px)"> <div className="flex min-w-0 flex-auto items-center rounded-full bg-transparent px-[clamp(10px,3vw,14px)]">
<textarea <textarea
ref={innerRef} ref={innerRef}
className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)" className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)"
+33 -15
View File
@@ -1,29 +1,43 @@
"use client"; "use client";
import { ArrowUp } from "lucide-react"; import { ArrowUp, Plus, X } from "lucide-react";
export interface ChatSendButtonProps { export interface ChatSendButtonProps {
disabled: boolean; disabled: boolean;
hasContent: boolean; hasContent: boolean;
isMenuOpen: boolean;
menuId: string;
onClick: () => void; onClick: () => void;
onMenuToggle: () => void;
onPointerDownSend: () => void; onPointerDownSend: () => void;
} }
export function ChatSendButton({ export function ChatSendButton({
disabled, disabled,
hasContent, hasContent,
isMenuOpen,
menuId,
onClick, onClick,
onMenuToggle,
onPointerDownSend, onPointerDownSend,
}: ChatSendButtonProps) { }: ChatSendButtonProps) {
const isActive = hasContent && !disabled; const isSendMode = hasContent;
const label = isSendMode
? "Send message"
: isMenuOpen
? "Close chat actions"
: "Open chat actions";
return ( return (
<button <button
type="button" type="button"
data-analytics-ignore data-analytics-ignore={isSendMode ? true : undefined}
data-analytics-key={isSendMode ? undefined : "chat.toggle_actions"}
className={[ className={[
"flex aspect-square size-(--chat-send-button-size,40px) shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-(--color-button-gradient-end,#fc69df) text-white transition-[background,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", "flex aspect-square size-(--chat-send-button-size,42px) shrink-0 cursor-pointer items-center justify-center rounded-full border transition-[background,color,transform,box-shadow] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f657a0]",
isActive isSendMode
? "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]" ? "border-transparent bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-white shadow-[0_8px_20px_rgba(246,87,160,0.3)]"
: "bg-[#f8a8ce] text-[rgba(255,255,255,0.88)] shadow-none", : isMenuOpen
? "border-transparent bg-[#38262d] text-white shadow-[0_8px_18px_rgba(56,38,45,0.18)]"
: "border-[rgba(104,67,80,0.09)] bg-[#f8f1f4] text-[#76505f] shadow-none",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
@@ -32,17 +46,21 @@ export function ChatSendButton({
if (event.pointerType === "mouse") return; if (event.pointerType === "mouse") return;
if (disabled) return; if (disabled) return;
event.preventDefault(); event.preventDefault();
if (!hasContent) return; if (!isSendMode) return;
onPointerDownSend(); onPointerDownSend();
}} }}
onClick={onClick} onClick={isSendMode ? onClick : onMenuToggle}
aria-label="Send message" aria-label={label}
aria-expanded={isSendMode ? undefined : isMenuOpen}
aria-controls={isSendMode ? undefined : menuId}
> >
<ArrowUp {isSendMode ? (
className="size-(--icon-size-xl,24px) text-(length:--icon-size-xl,24px) leading-none" <ArrowUp size={23} strokeWidth={2.4} aria-hidden="true" />
size={24} ) : isMenuOpen ? (
aria-hidden="true" <X size={22} strokeWidth={2.2} aria-hidden="true" />
/> ) : (
<Plus size={23} strokeWidth={2.2} aria-hidden="true" />
)}
</button> </button>
); );
} }
@@ -64,4 +64,68 @@ describe("PrivateAlbumCard interactions", () => {
expect(onOpenGallery).toHaveBeenCalledOnce(); expect(onOpenGallery).toHaveBeenCalledOnce();
expect(onOpenGallery).toHaveBeenCalledWith(3); expect(onOpenGallery).toHaveBeenCalledWith(3);
}); });
it("unlocks only from the collection CTA and disables repeat actions", () => {
const onUnlock = vi.fn();
const album = PrivateAlbumSchema.parse({
albumId: "album-locked",
title: "Locked afternoon",
imageCount: 3,
images: [
{ url: "/images/private-room/locked.png", locked: true, index: 0 },
],
locked: true,
unlocked: false,
unlockCost: 40,
lockDetail: { locked: true },
});
renderLockedCard(album, false, onUnlock);
const avatar = container.querySelector<HTMLElement>(
'[aria-label="Elio Silvestri locked collection"]',
);
act(() => avatar?.parentElement?.click());
expect(onUnlock).not.toHaveBeenCalled();
const button = findCollectionButton();
act(() => button.click());
expect(onUnlock).toHaveBeenCalledOnce();
renderLockedCard(album, true, onUnlock);
const disabledButton = findCollectionButton();
expect(disabledButton.disabled).toBe(true);
expect(disabledButton.textContent).toBe("Opening...");
act(() => disabledButton.click());
expect(onUnlock).toHaveBeenCalledOnce();
});
function renderLockedCard(
album: ReturnType<typeof PrivateAlbumSchema.parse>,
isUnlocking: boolean,
onUnlock: () => void,
): void {
act(() => {
root.render(
<PrivateAlbumCard
album={album}
displayName="Elio Silvestri"
avatarUrl="/images/avatar/elio.png"
index={0}
isUnlocking={isUnlocking}
onOpenGallery={() => undefined}
onUnlock={onUnlock}
/>,
);
});
}
function findCollectionButton(): HTMLButtonElement {
const button = Array.from(container.querySelectorAll("button")).find(
(item) =>
item.textContent === "View collection" ||
item.textContent === "Opening...",
);
if (!button) throw new Error("Missing View collection button");
return button;
}
}); });
@@ -101,7 +101,7 @@ describe("PrivateAlbumCard", () => {
expect(html).not.toContain('data-image-index="9"'); expect(html).not.toContain('data-image-index="9"');
}); });
it("uses the backend first image as the locked cover", () => { it("renders the reference-style locked collection over the first image", () => {
const html = renderCard( const html = renderCard(
makeAlbum({ makeAlbum({
content: null, content: null,
@@ -114,13 +114,39 @@ describe("PrivateAlbumCard", () => {
expect(html).toContain("elio.png"); expect(html).toContain("elio.png");
expect(html).toContain( expect(html).toContain(
'aria-label="Unlock 8 private room photos from Elio Silvestri"', 'aria-label="Elio Silvestri locked collection"',
); );
expect(html).toContain("Only for you."); expect(html).toContain("lucide-copy");
expect(html).toContain("320 credits"); expect(html).toContain("lucide-lock-keyhole");
expect(html).toContain("Unlock to view");
expect(html).toContain("8 Images");
expect(html).toContain("View collection");
expect(html).toContain(
'aria-label="View locked collection with 8 images from Elio Silvestri"',
);
expect(html).not.toContain("Only for you.");
expect(html).not.toContain("320 credits");
expect(html).not.toContain("Videos");
expect(html).toContain('data-analytics-key="private_album.unlock"'); expect(html).toContain('data-analytics-key="private_album.unlock"');
}); });
it("uses singular image copy and works without a cover", () => {
const html = renderCard(
makeAlbum({
imageCount: 0,
locked: true,
unlocked: false,
lockDetail: { locked: true },
images: [{ url: "", locked: true, index: 0 }],
}),
);
expect(html).toContain("1 Image");
expect(html).not.toContain("1 Images");
expect(html.match(/<img/g)).toHaveLength(2);
expect(html).toContain("View collection");
});
it("renders an empty cover when an unlocked album has no image", () => { it("renders an empty cover when an unlocked album has no image", () => {
const html = renderCard(makeAlbum()); const html = renderCard(makeAlbum());
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import Image from "next/image"; import Image from "next/image";
import { ImageIcon, LockKeyhole } from "lucide-react"; import { Copy, ImageIcon, LockKeyhole } from "lucide-react";
import { CharacterAvatar } from "@/app/_components"; import { CharacterAvatar } from "@/app/_components";
import type { PrivateAlbum } from "@/data/schemas/private-room"; import type { PrivateAlbum } from "@/data/schemas/private-room";
@@ -78,39 +78,59 @@ export function PrivateAlbumCard({
</header> </header>
{isLocked ? ( {isLocked ? (
<button <div className={styles.lockedPreview}>
type="button" <div className={styles.lockedPreviewBackdrop} aria-hidden="true">
data-analytics-key="private_album.unlock" {firstImageUrl ? (
data-analytics-label="Unlock private album" <Image
className={styles.lockedPreview} src={firstImageUrl}
disabled={isUnlocking} alt=""
onClick={onUnlock} fill
aria-label={`Unlock ${photoCount} private room photos from ${displayName}`} sizes="(max-width: 540px) calc(100vw - 36px), 484px"
> className={styles.lockedCoverImage}
{firstImageUrl ? ( />
<Image ) : null}
src={firstImageUrl} <span className={styles.lockedPreviewScrim} />
alt=""
fill
sizes="(max-width: 540px) calc(100vw - 36px), 484px"
className={styles.lockedCoverImage}
/>
) : null}
<span className={styles.lockedPreviewScrim} aria-hidden="true" />
<div className={styles.lockedPreviewContent}>
<div className={styles.previewIcon}>
<LockKeyhole size={22} aria-hidden="true" />
</div>
<div className={styles.previewText}>
<span>{album.previewText || "Unlock to view"}</span>
<strong>{album.unlockCost} credits</strong>
<small>
<ImageIcon size={13} aria-hidden="true" />
{photoCount} {photoCount === 1 ? "photo" : "photos"}
</small>
</div>
</div> </div>
</button> <span className={styles.lockedCollectionIcon} aria-hidden="true">
<Copy size={17} strokeWidth={2} />
</span>
<div
className={styles.lockedPreviewAvatarFrame}
role="img"
aria-label={`${displayName} locked collection`}
>
<CharacterAvatar
src={avatarUrl}
alt=""
size="100%"
imageSize={96}
className={styles.lockedPreviewAvatar}
/>
<span className={styles.lockedAvatarBadge} aria-hidden="true">
<LockKeyhole size={18} strokeWidth={2.2} />
</span>
</div>
<div className={styles.lockedPreviewContent}>
<strong className={styles.lockedPreviewTitle}>Unlock to view</strong>
<span className={styles.lockedImageCount}>
<ImageIcon size={19} strokeWidth={1.9} aria-hidden="true" />
{photoCount} {photoCount === 1 ? "Image" : "Images"}
</span>
<button
type="button"
data-analytics-key="private_album.unlock"
data-analytics-label="Unlock private album"
className={styles.lockedPreviewCta}
disabled={isUnlocking}
onClick={onUnlock}
aria-label={`View locked collection with ${photoCount} ${photoCount === 1 ? "image" : "images"} from ${displayName}`}
>
{isUnlocking ? "Opening..." : "View collection"}
</button>
</div>
</div>
) : previewImages.length > 0 ? ( ) : previewImages.length > 0 ? (
<div <div
className={`${styles.mediaGrid} ${gridLayoutClassName}`} className={`${styles.mediaGrid} ${gridLayoutClassName}`}
@@ -289,99 +289,148 @@
position: relative; position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
aspect-ratio: 4 / 5; min-height: clamp(230px, 56vw, 280px);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 14px; margin-top: clamp(54px, 13.333vw, 68px);
padding: clamp(16px, 4.074vw, 22px); padding: clamp(66px, 16.296vw, 82px) clamp(18px, 4.63vw, 25px)
border: 1px solid rgba(255, 116, 159, 0.24); clamp(22px, 5.556vw, 30px);
border-radius: clamp(20px, 5.185vw, 28px); border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: clamp(20px, 5.185vw, 26px);
background: background:
radial-gradient(circle at 22% 18%, rgba(255, 255, 255, 0.94), transparent 29%), radial-gradient(circle at 74% 12%, rgba(255, 178, 115, 0.28), transparent 42%),
radial-gradient(circle at 82% 82%, rgba(255, 103, 224, 0.18), transparent 35%), linear-gradient(145deg, #47434d, #29262d);
linear-gradient(145deg, rgba(255, 246, 239, 0.98), rgba(255, 225, 239, 0.94)), color: #ffffff;
#ffffff; isolation: isolate;
color: #21171b;
cursor: pointer;
font: inherit;
overflow: hidden;
text-align: center; text-align: center;
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82), inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 14px 32px rgba(255, 116, 159, 0.14); 0 16px 34px rgba(34, 25, 29, 0.18);
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
} }
.lockedPreview:disabled { .lockedPreviewBackdrop {
cursor: wait; position: absolute;
opacity: 0.72; inset: 0;
z-index: 0;
overflow: hidden;
border-radius: inherit;
} }
.lockedPreviewContent { .lockedPreviewContent {
position: relative; position: relative;
z-index: 2; z-index: 3;
display: flex; display: flex;
max-width: 260px; width: min(100%, 300px);
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 14px; gap: 8px;
} }
.lockedCoverImage { .lockedCoverImage {
object-fit: cover; object-fit: cover;
filter: blur(18px); filter: blur(16px) saturate(0.78);
transform: scale(1.08); transform: scale(1.12);
} }
.lockedPreviewScrim { .lockedPreviewScrim {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1; z-index: 1;
background: rgba(28, 19, 23, 0.46); background:
linear-gradient(180deg, rgba(18, 17, 21, 0.3), rgba(18, 17, 21, 0.68)),
rgba(31, 28, 34, 0.34);
} }
.previewIcon { .lockedCollectionIcon {
position: absolute;
top: 14px;
left: 14px;
z-index: 3;
display: grid; display: grid;
width: 58px; width: 28px;
height: 58px; height: 28px;
place-items: center; place-items: center;
border: 1px solid rgba(255, 255, 255, 0.68); border: 1px solid rgba(255, 255, 255, 0.36);
border-radius: 20px; border-radius: 7px;
background: linear-gradient(135deg, #ff7ac3, var(--color-accent, #f84d96)); background: rgba(24, 23, 28, 0.52);
color: #ffffff; color: #ffffff;
box-shadow: 0 14px 28px rgba(248, 77, 150, 0.26); box-shadow: 0 5px 14px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
} }
.previewText { .lockedPreviewAvatarFrame {
display: flex; position: absolute;
min-width: 0; top: clamp(-45px, -9.259vw, -36px);
flex-direction: column; left: 50%;
align-items: center; z-index: 4;
gap: 8px; width: clamp(82px, 20.741vw, 100px);
text-align: center; height: clamp(82px, 20.741vw, 100px);
border: 3px solid rgba(255, 255, 255, 0.94);
border-radius: 999px;
background: #ffffff;
box-shadow: 0 12px 28px rgba(29, 21, 25, 0.28);
transform: translateX(-50%);
} }
.previewText strong { .lockedPreviewAvatar {
color: #21171b; border-radius: 999px;
font-size: clamp(16px, 4.074vw, 21px);
font-weight: 930;
line-height: 1.05;
} }
.previewText span { .lockedAvatarBadge {
color: #4c3a40; position: absolute;
font-size: clamp(13px, 3.333vw, 17px); right: -3px;
font-weight: 780; bottom: 0;
display: grid;
width: clamp(30px, 7.778vw, 38px);
height: clamp(30px, 7.778vw, 38px);
place-items: center;
border: 3px solid rgba(255, 255, 255, 0.94);
border-radius: 999px;
background: #ffffff;
color: #29262d;
box-shadow: 0 6px 14px rgba(22, 17, 19, 0.2);
} }
.previewText small { .lockedPreviewTitle {
color: #ffffff;
font-size: clamp(19px, 4.815vw, 26px);
font-weight: 850;
line-height: 1.15;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.28);
}
.lockedImageCount {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 5px; gap: 7px;
color: #a94c64; color: rgba(255, 255, 255, 0.92);
font-size: clamp(12px, 2.963vw, 15px); font-size: clamp(14px, 3.333vw, 17px);
font-weight: 760; font-weight: 620;
line-height: 1.2;
}
.lockedPreviewCta {
min-width: min(100%, 178px);
min-height: 46px;
margin-top: 22px;
padding: 0 24px;
border: 0;
border-radius: 999px;
background: #ffffff;
color: #28252a;
cursor: pointer;
font: inherit;
font-size: clamp(14px, 3.519vw, 17px);
font-weight: 850;
box-shadow: 0 10px 24px rgba(18, 14, 16, 0.22);
transition: transform 0.16s ease, box-shadow 0.16s ease,
background 0.16s ease;
}
.lockedPreviewCta:disabled {
cursor: wait;
opacity: 0.72;
} }
.mediaGrid { .mediaGrid {
@@ -666,12 +715,17 @@
text-align: center; text-align: center;
} }
.primaryCta:hover, .primaryCta:hover {
.lockedPreview:hover {
filter: brightness(1.04); filter: brightness(1.04);
transform: translateY(-1px); transform: translateY(-1px);
} }
.lockedPreviewCta:hover:not(:disabled) {
background: #f7f5f6;
box-shadow: 0 12px 28px rgba(18, 14, 16, 0.28);
transform: translateY(-1px);
}
.mediaGridItem:hover { .mediaGridItem:hover {
filter: brightness(1.02); filter: brightness(1.02);
} }
@@ -681,8 +735,11 @@
background: rgba(38, 38, 38, 0.82); background: rgba(38, 38, 38, 0.82);
} }
.primaryCta:active, .primaryCta:active {
.lockedPreview:active { transform: translateY(1px) scale(0.99);
}
.lockedPreviewCta:active:not(:disabled) {
transform: translateY(1px) scale(0.99); transform: translateY(1px) scale(0.99);
} }
@@ -699,7 +756,7 @@
} }
.primaryCta:focus-visible, .primaryCta:focus-visible,
.lockedPreview:focus-visible, .lockedPreviewCta:focus-visible,
.mediaGridItem:focus-visible, .mediaGridItem:focus-visible,
.galleryClose:focus-visible, .galleryClose:focus-visible,
.galleryNav:focus-visible, .galleryNav:focus-visible,
@@ -5,7 +5,10 @@ import memoryDriver from "unstorage/drivers/memory";
import { StorageKeys } from "@/data/storage/storage_keys"; import { StorageKeys } from "@/data/storage/storage_keys";
import { SessionAsyncUtil } from "@/utils/session-storage"; import { SessionAsyncUtil } from "@/utils/session-storage";
import { NavigationStorage } from "../navigation_storage"; import {
createPendingChatPromotion,
NavigationStorage,
} from "../navigation_storage";
const CHARACTER_ID = "elio"; const CHARACTER_ID = "elio";
const OTHER_CHARACTER_ID = "maya-tan"; const OTHER_CHARACTER_ID = "maya-tan";
@@ -105,6 +108,28 @@ describe("NavigationStorage", () => {
}); });
}); });
it("creates manual promotions without persisting them", async () => {
const image = createPendingChatPromotion("image", CHARACTER_ID);
const voice = createPendingChatPromotion("voice", CHARACTER_ID);
expect(image).toMatchObject({
characterId: CHARACTER_ID,
promotionType: "image",
lockType: "image_paywall",
});
expect(voice).toMatchObject({
characterId: CHARACTER_ID,
promotionType: "voice",
lockType: "voice_message",
});
expect(image.clientLockId).toMatch(/^promotion_/);
expect(voice.clientLockId).toMatch(/^promotion_/);
expect(voice.clientLockId).not.toBe(image.clientLockId);
await expect(
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
).resolves.toBeNull();
});
it("saves and consumes pending chat image return sessions", async () => { it("saves and consumes pending chat image return sessions", async () => {
await NavigationStorage.savePendingChatImageReturn({ await NavigationStorage.savePendingChatImageReturn({
characterId: CHARACTER_ID, characterId: CHARACTER_ID,
@@ -69,6 +69,19 @@ export type PendingChatImageReturn = z.output<
typeof PendingChatImageReturnSchema typeof PendingChatImageReturnSchema
>; >;
export function createPendingChatPromotion(
promotionType: PendingChatPromotionType,
characterId: string,
): PendingChatPromotion {
return PendingChatPromotionSchema.parse({
characterId,
promotionType,
lockType: toPromotionLockType(promotionType),
clientLockId: `promotion_${createUuidV4()}`,
createdAt: Date.now(),
});
}
/** /**
* NavigationStorage owns short-lived cross-route sessions. * NavigationStorage owns short-lived cross-route sessions.
* *
@@ -180,13 +193,10 @@ export class NavigationStorage {
promotionType: PendingChatPromotionType, promotionType: PendingChatPromotionType,
characterId: string, characterId: string,
): Promise<PendingChatPromotion> { ): Promise<PendingChatPromotion> {
const promotion: PendingChatPromotion = { const promotion = createPendingChatPromotion(
characterId,
promotionType, promotionType,
lockType: toPromotionLockType(promotionType), characterId,
clientLockId: `promotion_${createUuidV4()}`, );
createdAt: Date.now(),
};
await SessionAsyncUtil.setJson( await SessionAsyncUtil.setJson(
StorageKeys.pendingChatPromotion, StorageKeys.pendingChatPromotion,
promotion, promotion,
@@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
createPendingChatPromotion as createPromotion,
NavigationStorage, NavigationStorage,
type PendingChatUnlock, type PendingChatUnlock,
type PendingChatUnlockKind, type PendingChatUnlockKind,
@@ -17,6 +18,13 @@ export type {
PendingChatUnlockStage, PendingChatUnlockStage,
}; };
export function createPendingChatPromotion(
promotionType: PendingChatPromotionType,
characterId: string,
): PendingChatPromotion {
return createPromotion(promotionType, characterId);
}
export async function savePendingChatUnlock(input: { export async function savePendingChatUnlock(input: {
characterId: string; characterId: string;
displayMessageId?: string; displayMessageId?: string;