Add chat gifts and relationship diary UI
This commit is contained in:
+2
-2
@@ -23,12 +23,12 @@ const nextConfig: NextConfig = {
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**.supabase.co",
|
||||
pathname: "/storage/v1/object/public/**",
|
||||
pathname: "/storage/v1/object/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "dbapi.banlv-ai.com",
|
||||
pathname: "/storage/v1/object/public/**",
|
||||
pathname: "/storage/v1/object/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
}
|
||||
@@ -91,7 +91,10 @@ describe("chat Tailwind components", () => {
|
||||
<ChatSendButton
|
||||
disabled={false}
|
||||
hasContent={true}
|
||||
isMenuOpen={false}
|
||||
menuId="chat-actions"
|
||||
onClick={() => undefined}
|
||||
onMenuToggle={() => undefined}
|
||||
onPointerDownSend={() => undefined}
|
||||
/>,
|
||||
);
|
||||
@@ -99,7 +102,21 @@ describe("chat Tailwind components", () => {
|
||||
<ChatSendButton
|
||||
disabled={false}
|
||||
hasContent={false}
|
||||
isMenuOpen={false}
|
||||
menuId="chat-actions"
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
@@ -107,7 +124,10 @@ describe("chat Tailwind components", () => {
|
||||
<ChatSendButton
|
||||
disabled={true}
|
||||
hasContent={true}
|
||||
isMenuOpen={false}
|
||||
menuId="chat-actions"
|
||||
onClick={() => undefined}
|
||||
onMenuToggle={() => undefined}
|
||||
onPointerDownSend={() => undefined}
|
||||
/>,
|
||||
);
|
||||
@@ -115,12 +135,17 @@ describe("chat Tailwind components", () => {
|
||||
expect(activeHtml).toContain('aria-label="Send message"');
|
||||
expect(activeHtml).toContain('data-analytics-ignore="true"');
|
||||
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(
|
||||
"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("text-[rgba(255,255,255,0.88)]");
|
||||
expect(emptyHtml).toContain('aria-label="Open chat actions"');
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -306,6 +306,7 @@ function renderMessagesWithDateHeaders(
|
||||
lockReason={item.message.lockReason}
|
||||
lockedPrivate={item.message.lockedPrivate}
|
||||
privateMessageHint={item.message.privateMessageHint}
|
||||
commercialAction={item.message.commercialAction}
|
||||
isUnlockingMessage={
|
||||
isUnlockingMessage === true &&
|
||||
item.message.displayId === unlockingMessageId
|
||||
|
||||
@@ -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. */
|
||||
|
||||
.bar {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
flex: 0 0 auto;
|
||||
padding: 0
|
||||
padding: clamp(7px, 1.852vw, 10px)
|
||||
calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px))
|
||||
max(
|
||||
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));
|
||||
background: transparent;
|
||||
transition: padding-top 0.2s ease, padding-bottom 0.2s ease,
|
||||
background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: padding-bottom 0.2s ease;
|
||||
}
|
||||
|
||||
.barFocused {
|
||||
padding-top: var(--spacing-md, 12px);
|
||||
background: #feeff2;
|
||||
box-shadow: 0 4px 12px var(--color-input-box-shadow, rgba(0, 0, 0, 0.1));
|
||||
.composer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 内层:白底 + 大圆角(Dart AppRadius.radius32)+ focused 时 accent 边框 */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: var(--spacing-sm, 8px);
|
||||
background: #fff;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(94, 62, 73, 0.11);
|
||||
border-radius: var(--radius-full, 999px);
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
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 {
|
||||
border-color: var(--color-accent, #f84d96);
|
||||
border-color: rgba(246, 87, 160, 0.62);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(246, 87, 160, 0.1),
|
||||
0 14px 34px rgba(92, 52, 67, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 350px) {
|
||||
.bar {
|
||||
padding-right: calc(12px + var(--app-safe-right, 0px));
|
||||
padding-left: calc(12px + var(--app-safe-left, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createPendingChatPromotion } from "@/lib/navigation/chat_unlock_session";
|
||||
import { recordTipOfferAction } from "@/lib/commercial/tip_offer_session";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
useActiveCharacterRoutes,
|
||||
} from "@/providers/character-provider";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { Logger } from "@/utils/logger";
|
||||
|
||||
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
|
||||
import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics";
|
||||
import { ChatComposerActionMenu } from "./chat-composer-action-menu";
|
||||
import { ChatInputTextField } from "./chat-input-text-field";
|
||||
import { ChatSendButton } from "./chat-send-button";
|
||||
import styles from "./chat-input-bar.module.css";
|
||||
|
||||
const log = new Logger("AppChatComponentsChatInputBar");
|
||||
const POINTER_SEND_CLICK_DEDUPE_MS = 500;
|
||||
const CHAT_ACTION_MENU_ID = "chat-composer-action-menu";
|
||||
|
||||
export interface ChatInputBarProps {
|
||||
disabled?: boolean;
|
||||
@@ -19,22 +27,57 @@ export interface ChatInputBarProps {
|
||||
|
||||
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
const dispatch = useChatDispatch();
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const [input, setInput] = useState("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
|
||||
const [previousDisabled, setPreviousDisabled] = useState(disabled);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lastPointerSendAtRef = useRef(0);
|
||||
|
||||
const hasContent = input.trim().length > 0;
|
||||
|
||||
if (disabled !== previousDisabled) {
|
||||
setPreviousDisabled(disabled);
|
||||
if (disabled && isActionMenuOpen) setIsActionMenuOpen(false);
|
||||
}
|
||||
|
||||
useChatKeyboardAvoidance({
|
||||
active: isFocused,
|
||||
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) => {
|
||||
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") => {
|
||||
@@ -62,6 +105,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
});
|
||||
dispatch({ type: "ChatSendMessage", content: input });
|
||||
setInput("");
|
||||
setIsActionMenuOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
@@ -70,26 +114,61 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
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 (
|
||||
<div
|
||||
ref={barRef}
|
||||
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}
|
||||
>
|
||||
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
|
||||
<ChatInputTextField
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onSubmit={() => handleSend("keyboard")}
|
||||
onFocusChange={setIsFocused}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ChatSendButton
|
||||
disabled={disabled}
|
||||
hasContent={hasContent}
|
||||
onClick={() => handleSend("click")}
|
||||
onPointerDownSend={handlePointerDownSend}
|
||||
/>
|
||||
<div ref={barRef} className={styles.bar}>
|
||||
<div className={styles.composer}>
|
||||
{isActionMenuOpen ? (
|
||||
<ChatComposerActionMenu
|
||||
id={CHAT_ACTION_MENU_ID}
|
||||
disabled={disabled}
|
||||
tipHref={characterRoutes.tip}
|
||||
onImage={() => handlePromotion("image")}
|
||||
onVoice={() => handlePromotion("voice")}
|
||||
onNavigate={() => {
|
||||
recordTipOfferAction(character.id, "opened", "manualMenu");
|
||||
setIsActionMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}
|
||||
>
|
||||
<ChatInputTextField
|
||||
ref={textareaRef}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ export const ChatInputTextField = forwardRef<
|
||||
};
|
||||
|
||||
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
|
||||
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)"
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
"use client";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, Plus, X } from "lucide-react";
|
||||
|
||||
export interface ChatSendButtonProps {
|
||||
disabled: boolean;
|
||||
hasContent: boolean;
|
||||
isMenuOpen: boolean;
|
||||
menuId: string;
|
||||
onClick: () => void;
|
||||
onMenuToggle: () => void;
|
||||
onPointerDownSend: () => void;
|
||||
}
|
||||
|
||||
export function ChatSendButton({
|
||||
disabled,
|
||||
hasContent,
|
||||
isMenuOpen,
|
||||
menuId,
|
||||
onClick,
|
||||
onMenuToggle,
|
||||
onPointerDownSend,
|
||||
}: ChatSendButtonProps) {
|
||||
const isActive = hasContent && !disabled;
|
||||
const isSendMode = hasContent;
|
||||
const label = isSendMode
|
||||
? "Send message"
|
||||
: isMenuOpen
|
||||
? "Close chat actions"
|
||||
: "Open chat actions";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-ignore
|
||||
data-analytics-ignore={isSendMode ? true : undefined}
|
||||
data-analytics-key={isSendMode ? undefined : "chat.toggle_actions"}
|
||||
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))]",
|
||||
isActive
|
||||
? "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]"
|
||||
: "bg-[#f8a8ce] text-[rgba(255,255,255,0.88)] shadow-none",
|
||||
"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]",
|
||||
isSendMode
|
||||
? "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)]"
|
||||
: 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)
|
||||
.join(" ")}
|
||||
@@ -32,17 +46,21 @@ export function ChatSendButton({
|
||||
if (event.pointerType === "mouse") return;
|
||||
if (disabled) return;
|
||||
event.preventDefault();
|
||||
if (!hasContent) return;
|
||||
if (!isSendMode) return;
|
||||
onPointerDownSend();
|
||||
}}
|
||||
onClick={onClick}
|
||||
aria-label="Send message"
|
||||
onClick={isSendMode ? onClick : onMenuToggle}
|
||||
aria-label={label}
|
||||
aria-expanded={isSendMode ? undefined : isMenuOpen}
|
||||
aria-controls={isSendMode ? undefined : menuId}
|
||||
>
|
||||
<ArrowUp
|
||||
className="size-(--icon-size-xl,24px) text-(length:--icon-size-xl,24px) leading-none"
|
||||
size={24}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{isSendMode ? (
|
||||
<ArrowUp size={23} strokeWidth={2.4} aria-hidden="true" />
|
||||
) : isMenuOpen ? (
|
||||
<X size={22} strokeWidth={2.2} aria-hidden="true" />
|
||||
) : (
|
||||
<Plus size={23} strokeWidth={2.2} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
.card {
|
||||
position: relative;
|
||||
width: min(100%, 286px);
|
||||
padding: 12px 42px 12px 13px;
|
||||
border: 1px solid rgba(122, 91, 73, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 250, 246, 0.96);
|
||||
color: #352a25;
|
||||
box-shadow: 0 5px 18px rgba(68, 47, 37, 0.08);
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #756660;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dismiss:hover,
|
||||
.dismiss:focus-visible {
|
||||
background: rgba(104, 82, 70, 0.1);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 7px;
|
||||
background: #765142;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cta span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cta:hover,
|
||||
.cta:focus-visible {
|
||||
background: #604035;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Coffee, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CommercialAction } from "@/data/schemas/chat";
|
||||
import { recordTipOfferAction } from "@/lib/commercial/tip_offer_session";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
|
||||
import styles from "./commercial-action-card.module.css";
|
||||
|
||||
export interface CommercialActionCardProps {
|
||||
action: CommercialAction;
|
||||
characterId: string;
|
||||
}
|
||||
|
||||
export function CommercialActionCard({
|
||||
action,
|
||||
characterId,
|
||||
}: CommercialActionCardProps) {
|
||||
const routes = useActiveCharacterRoutes();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed || action.type !== "giftOffer") return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={styles.card}
|
||||
aria-label="Gift invitation"
|
||||
data-commercial-action-id={action.actionId}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dismiss}
|
||||
aria-label="Dismiss gift invitation"
|
||||
title="Dismiss"
|
||||
onClick={() => {
|
||||
setDismissed(true);
|
||||
recordTipOfferAction(characterId, "dismissed", "aiContext");
|
||||
}}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<p>{action.copy}</p>
|
||||
<Link
|
||||
href={routes.tip}
|
||||
className={styles.cta}
|
||||
onClick={() =>
|
||||
recordTipOfferAction(characterId, "opened", "aiContext")
|
||||
}
|
||||
>
|
||||
<Coffee size={17} aria-hidden="true" />
|
||||
<span>{action.ctaLabel}</span>
|
||||
</Link>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
* - 用户:[占位 spacer] [Content] [Avatar]
|
||||
*/
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
import type { CommercialAction } from "@/data/schemas/chat";
|
||||
|
||||
import { MessageAvatar } from "./message-avatar";
|
||||
import { MessageContent } from "./message-content";
|
||||
@@ -32,6 +33,7 @@ export interface MessageBubbleProps {
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
onUnlockImageMessage?: ChatMessageAction;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
commercialAction?: CommercialAction | null;
|
||||
}
|
||||
|
||||
type ChatMessageAction = (
|
||||
@@ -57,6 +59,7 @@ export function MessageBubble({
|
||||
onUnlockVoiceMessage,
|
||||
onUnlockImageMessage,
|
||||
onOpenImage,
|
||||
commercialAction,
|
||||
}: MessageBubbleProps) {
|
||||
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
|
||||
|
||||
@@ -87,6 +90,7 @@ export function MessageBubble({
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onUnlockImageMessage={onUnlockImageMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
commercialAction={commercialAction}
|
||||
/>
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
</div>
|
||||
@@ -118,6 +122,7 @@ export function MessageBubble({
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onUnlockImageMessage={onUnlockImageMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
commercialAction={commercialAction}
|
||||
/>
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
import type { CommercialAction } from "@/data/schemas/chat";
|
||||
|
||||
import { CommercialActionCard } from "./commercial-action-card";
|
||||
import { ImageBubble } from "./image-bubble";
|
||||
import { LockedImageMessageCard } from "./locked-image-message-card";
|
||||
import { PrivateMessageCard } from "./private-message-card";
|
||||
@@ -24,6 +27,7 @@ export interface MessageContentProps {
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
onUnlockImageMessage?: ChatMessageAction;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
commercialAction?: CommercialAction | null;
|
||||
}
|
||||
|
||||
type ChatMessageAction = (
|
||||
@@ -51,6 +55,7 @@ export function MessageContent({
|
||||
onUnlockVoiceMessage,
|
||||
onUnlockImageMessage,
|
||||
onOpenImage,
|
||||
commercialAction,
|
||||
}: MessageContentProps) {
|
||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||
@@ -125,6 +130,12 @@ export function MessageContent({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{isFromAI && commercialAction ? (
|
||||
<CommercialActionCard
|
||||
action={commercialAction}
|
||||
characterId={characterId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./private-album-card";
|
||||
export * from "./private-album-gallery";
|
||||
export * from "./relationship-diary-panel";
|
||||
export * from "./status-card";
|
||||
export * from "./unlock-confirm-dialog";
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
.section {
|
||||
padding: 18px 18px 104px;
|
||||
}
|
||||
|
||||
.section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 2px 0 0;
|
||||
color: #252a28;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
color: #7c6057;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.balance {
|
||||
display: inline-flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #d8d5ca;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #4f554f;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.diary {
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
border: 1px solid #dcddd8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 5px 18px rgba(44, 49, 46, 0.06);
|
||||
}
|
||||
|
||||
.diary[data-locked="true"] {
|
||||
background: #fffdfb;
|
||||
}
|
||||
|
||||
.diaryMeta {
|
||||
display: flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #727873;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.unread {
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #9f3348;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.diary h3 {
|
||||
margin: 8px 0 7px;
|
||||
color: #232724;
|
||||
font-size: 17px;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.teaser {
|
||||
margin: 0;
|
||||
color: #626864;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.unlockButton,
|
||||
.loadMore,
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border-radius: 7px;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unlockButton {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
border: 1px solid #b7a091;
|
||||
background: #f8f0ea;
|
||||
color: #5d4135;
|
||||
}
|
||||
|
||||
.unlockedContent {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.diaryImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 560px;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.unlockedContent p {
|
||||
margin: 14px 0 0;
|
||||
color: #363b38;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
border: 1px solid #cfd2cc;
|
||||
background: #fff;
|
||||
color: #4f5651;
|
||||
}
|
||||
|
||||
.loadMore svg[data-spinning="true"] {
|
||||
animation: diary-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.dialogBackdrop {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
padding: 18px;
|
||||
background: rgba(25, 28, 26, 0.48);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(100%, 460px);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 54px rgba(24, 27, 25, 0.24);
|
||||
}
|
||||
|
||||
.dialogIcon {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #edf1ed;
|
||||
color: #536359;
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
margin: 14px 0 7px;
|
||||
color: #262b28;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.dialog p {
|
||||
margin: 0;
|
||||
color: #626864;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialogMessage {
|
||||
margin-top: 11px !important;
|
||||
color: #8c3044 !important;
|
||||
}
|
||||
|
||||
.dialogActions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: 1px solid #d0d3ce;
|
||||
background: #fff;
|
||||
color: #4f5651;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
border: 1px solid #58685e;
|
||||
background: #58685e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.unlockButton:disabled,
|
||||
.loadMore:disabled,
|
||||
.primaryButton:disabled,
|
||||
.secondaryButton:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
@keyframes diary-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 541px) {
|
||||
.dialogBackdrop {
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { BookHeart, Coins, LockKeyhole, RefreshCw } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
RelationshipDiariesResponse,
|
||||
RelationshipDiary,
|
||||
} from "@/data/schemas/private-room";
|
||||
import { privateRoomApi } from "@/data/services/api/private_room_api";
|
||||
|
||||
import { StatusCard } from "./status-card";
|
||||
import styles from "./relationship-diary-panel.module.css";
|
||||
|
||||
export interface RelationshipDiaryPanelProps {
|
||||
active: boolean;
|
||||
characterId: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
registered: boolean;
|
||||
onRequireAuth: () => void;
|
||||
onTopUp: () => void;
|
||||
onUnreadCountChange: (count: number) => void;
|
||||
}
|
||||
|
||||
export function RelationshipDiaryPanel({
|
||||
active,
|
||||
characterId,
|
||||
displayName,
|
||||
enabled,
|
||||
registered,
|
||||
onRequireAuth,
|
||||
onTopUp,
|
||||
onUnreadCountChange,
|
||||
}: RelationshipDiaryPanelProps) {
|
||||
const [data, setData] = useState<RelationshipDiariesResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [pendingDiary, setPendingDiary] = useState<RelationshipDiary | null>(
|
||||
null,
|
||||
);
|
||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||
const [unlockMessage, setUnlockMessage] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (cursor?: string | null) => {
|
||||
if (!enabled) return;
|
||||
if (cursor) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await privateRoomApi.getDiaries({
|
||||
characterId,
|
||||
limit: 20,
|
||||
cursor,
|
||||
});
|
||||
setData((current) =>
|
||||
cursor && current
|
||||
? {
|
||||
...response,
|
||||
items: [...current.items, ...response.items],
|
||||
}
|
||||
: response,
|
||||
);
|
||||
onUnreadCountChange(response.unreadCount);
|
||||
|
||||
if (registered) {
|
||||
const unseen = response.items.filter((item) => !item.seen);
|
||||
if (unseen.length > 0) {
|
||||
void Promise.allSettled(
|
||||
unseen.map((item) =>
|
||||
privateRoomApi.markDiarySeen(item.diaryId),
|
||||
),
|
||||
).then(() => {
|
||||
setData((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
unreadCount: 0,
|
||||
items: current.items.map((item) => ({
|
||||
...item,
|
||||
seen: true,
|
||||
})),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
onUnreadCountChange(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(toMessage(error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[characterId, enabled, onUnreadCountChange, registered],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const timeoutId = globalThis.setTimeout(() => void load(), 0);
|
||||
return () => globalThis.clearTimeout(timeoutId);
|
||||
}, [characterId, enabled, load, registered]);
|
||||
|
||||
const requestUnlock = (diary: RelationshipDiary) => {
|
||||
if (!registered) {
|
||||
onRequireAuth();
|
||||
return;
|
||||
}
|
||||
setUnlockMessage(null);
|
||||
setPendingDiary(diary);
|
||||
};
|
||||
|
||||
const confirmUnlock = async () => {
|
||||
if (!pendingDiary || isUnlocking) return;
|
||||
setIsUnlocking(true);
|
||||
setUnlockMessage(null);
|
||||
try {
|
||||
const response = await privateRoomApi.unlockDiary(
|
||||
pendingDiary.diaryId,
|
||||
{ expectedCost: pendingDiary.unlockCostCredits },
|
||||
);
|
||||
if (response.unlocked) {
|
||||
if (response.diary) {
|
||||
setData((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
creditBalance: response.creditBalance,
|
||||
items: current.items.map((item) =>
|
||||
item.diaryId === response.diary?.diaryId
|
||||
? response.diary
|
||||
: item,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
setPendingDiary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.reason === "cost_changed") {
|
||||
const nextCost = response.requiredCredits;
|
||||
setPendingDiary((current) =>
|
||||
current ? { ...current, unlockCostCredits: nextCost } : current,
|
||||
);
|
||||
setData((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
items: current.items.map((item) =>
|
||||
item.diaryId === pendingDiary.diaryId
|
||||
? { ...item, unlockCostCredits: nextCost }
|
||||
: item,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setUnlockMessage(`The unlock price changed to ${nextCost} credits.`);
|
||||
} else if (response.reason === "insufficient_credits") {
|
||||
setUnlockMessage(
|
||||
`You need ${response.shortfallCredits} more credits to unlock this diary.`,
|
||||
);
|
||||
} else if (response.reason === "not_found") {
|
||||
setUnlockMessage("This diary is no longer available.");
|
||||
} else {
|
||||
setUnlockMessage("The unlock could not be completed. Please try again.");
|
||||
}
|
||||
} catch (error) {
|
||||
setUnlockMessage(toMessage(error));
|
||||
} finally {
|
||||
setIsUnlocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={styles.section}
|
||||
aria-labelledby="diaries-title"
|
||||
hidden={!active}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Diaries</p>
|
||||
<h2 id="diaries-title">Notes from {displayName}</h2>
|
||||
</div>
|
||||
<span className={styles.balance} aria-label="Credit balance">
|
||||
<Coins size={16} aria-hidden="true" />
|
||||
{data?.creditBalance ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!registered && !isLoading ? (
|
||||
<StatusCard
|
||||
message="Sign in to read and unlock your personal diaries."
|
||||
actionLabel="Sign in"
|
||||
onAction={onRequireAuth}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<StatusCard
|
||||
message={errorMessage}
|
||||
actionLabel="Retry"
|
||||
onAction={() => void load()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isLoading && !data ? <StatusCard message="Loading diaries..." /> : null}
|
||||
|
||||
{registered && !isLoading && data?.items.length === 0 && !errorMessage ? (
|
||||
<StatusCard message="No diary has been written yet." />
|
||||
) : null}
|
||||
|
||||
<div className={styles.timeline}>
|
||||
{data?.items.map((diary) => (
|
||||
<article
|
||||
key={diary.diaryId}
|
||||
className={styles.diary}
|
||||
data-locked={diary.locked ? "true" : "false"}
|
||||
>
|
||||
<div className={styles.diaryMeta}>
|
||||
<span>{formatDiaryDate(diary.diaryDate)}</span>
|
||||
{!diary.seen ? <span className={styles.unread}>New</span> : null}
|
||||
</div>
|
||||
<h3>{diary.title}</h3>
|
||||
<p className={styles.teaser}>{diary.teaserText}</p>
|
||||
|
||||
{diary.locked ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.unlockButton}
|
||||
onClick={() => requestUnlock(diary)}
|
||||
>
|
||||
<LockKeyhole size={17} aria-hidden="true" />
|
||||
<span>Unlock for {diary.unlockCostCredits} credits</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className={styles.unlockedContent}>
|
||||
{diary.imageUrl ? (
|
||||
<Image
|
||||
src={diary.imageUrl}
|
||||
alt=""
|
||||
width={680}
|
||||
height={850}
|
||||
sizes="(max-width: 540px) 92vw, 480px"
|
||||
className={styles.diaryImage}
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<p>{diary.content}</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data?.nextCursor ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.loadMore}
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void load(data.nextCursor)}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
data-spinning={isLoadingMore ? "true" : "false"}
|
||||
/>
|
||||
<span>{isLoadingMore ? "Loading..." : "Load earlier diaries"}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{pendingDiary ? (
|
||||
<div className={styles.dialogBackdrop} role="presentation">
|
||||
<section
|
||||
className={styles.dialog}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="diary-unlock-title"
|
||||
>
|
||||
<span className={styles.dialogIcon} aria-hidden="true">
|
||||
<BookHeart size={23} />
|
||||
</span>
|
||||
<h2 id="diary-unlock-title">Unlock this diary?</h2>
|
||||
<p>
|
||||
{pendingDiary.unlockCostCredits} credits will be deducted from
|
||||
your balance.
|
||||
</p>
|
||||
{unlockMessage ? (
|
||||
<p className={styles.dialogMessage} role="status">
|
||||
{unlockMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={styles.dialogActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
disabled={isUnlocking}
|
||||
onClick={() => setPendingDiary(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{unlockMessage?.includes("more credits") ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={onTopUp}
|
||||
>
|
||||
Get credits
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
disabled={isUnlocking}
|
||||
onClick={() => void confirmUnlock()}
|
||||
>
|
||||
{isUnlocking ? "Unlocking..." : "Unlock"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDiaryDate(value: string | null): string {
|
||||
if (!value) return "Private diary";
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
function toMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return "Something went wrong. Please try again.";
|
||||
}
|
||||
@@ -48,6 +48,61 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.postsSection[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collectionTabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
margin:
|
||||
20px
|
||||
calc(var(--app-safe-right, 0px) + 18px)
|
||||
0
|
||||
calc(var(--app-safe-left, 0px) + 18px);
|
||||
padding: 4px;
|
||||
border: 1px solid #d8dad5;
|
||||
border-radius: 8px;
|
||||
background: #e8ebe6;
|
||||
}
|
||||
|
||||
.collectionTabs button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #646a65;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collectionTabs button[data-active="true"] {
|
||||
background: #fff;
|
||||
color: #2b302d;
|
||||
box-shadow: 0 2px 8px rgba(44, 49, 46, 0.09);
|
||||
}
|
||||
|
||||
.tabBadge {
|
||||
display: inline-grid;
|
||||
min-width: 19px;
|
||||
height: 19px;
|
||||
place-items: center;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: #a4364c;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
PrivateAlbumCard,
|
||||
PrivateAlbumGallery,
|
||||
RelationshipDiaryPanel,
|
||||
StatusCard,
|
||||
UnlockConfirmDialog,
|
||||
} from "./components";
|
||||
@@ -48,6 +49,10 @@ export function PrivateRoomScreen() {
|
||||
const state = usePrivateRoomState();
|
||||
const dispatch = usePrivateRoomDispatch();
|
||||
const openedGalleryInPageRef = useRef(false);
|
||||
const [activeCollection, setActiveCollection] = useState<
|
||||
"albums" | "diaries"
|
||||
>("albums");
|
||||
const [diaryUnreadCount, setDiaryUnreadCount] = useState(0);
|
||||
const galleryState = useMemo(
|
||||
() => getPrivateAlbumGalleryState(searchParams),
|
||||
[searchParams],
|
||||
@@ -207,7 +212,35 @@ export function PrivateRoomScreen() {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className={styles.postsSection} aria-labelledby="posts-title">
|
||||
<div className={styles.collectionTabs} aria-label="Private room views">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeCollection === "albums"}
|
||||
data-active={activeCollection === "albums" ? "true" : "false"}
|
||||
onClick={() => setActiveCollection("albums")}
|
||||
>
|
||||
Albums
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeCollection === "diaries"}
|
||||
data-active={activeCollection === "diaries" ? "true" : "false"}
|
||||
onClick={() => setActiveCollection("diaries")}
|
||||
>
|
||||
Diaries
|
||||
{diaryUnreadCount > 0 ? (
|
||||
<span className={styles.tabBadge} aria-label={`${diaryUnreadCount} new`}>
|
||||
{Math.min(diaryUnreadCount, 99)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className={styles.postsSection}
|
||||
aria-labelledby="posts-title"
|
||||
hidden={activeCollection !== "albums"}
|
||||
>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Albums</p>
|
||||
@@ -259,6 +292,17 @@ export function PrivateRoomScreen() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RelationshipDiaryPanel
|
||||
active={activeCollection === "diaries"}
|
||||
characterId={character.id}
|
||||
displayName={displayName}
|
||||
enabled={authState.hasInitialized}
|
||||
registered={!isPrivateRoomAuthRequired(authState.loginStatus)}
|
||||
onRequireAuth={() => navigator.openAuth(characterRoutes.privateRoom)}
|
||||
onTopUp={handleTopUpClick}
|
||||
onUnreadCountChange={setDiaryUnreadCount}
|
||||
/>
|
||||
|
||||
<AppBottomNav
|
||||
activeItem="privateRoom"
|
||||
privateRoomLabel={character.copy.privateRoomTitle}
|
||||
|
||||
@@ -355,6 +355,7 @@ function makePaymentState(
|
||||
planCatalog: "tip",
|
||||
plans: [giftPlan],
|
||||
giftCategories: [giftCategory],
|
||||
giftOffer: null,
|
||||
giftProducts: [giftProduct],
|
||||
selectedGiftCategory: giftCategory.category,
|
||||
isFirstRecharge: false,
|
||||
|
||||
@@ -64,6 +64,7 @@ export function TipScreen({
|
||||
paymentType: "tip",
|
||||
shouldResumePendingOrder,
|
||||
});
|
||||
const offerPrompt = payment.giftOffer?.prompt.trim() || supportPrompt.prompt;
|
||||
|
||||
const selectedCategory =
|
||||
payment.giftCategories.find(
|
||||
@@ -236,7 +237,7 @@ export function TipScreen({
|
||||
supportPrompt.isReady ? styles.supportPromptReady : ""
|
||||
}`}
|
||||
>
|
||||
{supportPrompt.prompt}
|
||||
{offerPrompt}
|
||||
</p>
|
||||
|
||||
<div className={styles.purchaseFlow}>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ChatSendResponseSchema } from "@/data/schemas/chat";
|
||||
import { sendResponseToUiMessage } from "@/stores/chat/helper/message-mappers";
|
||||
|
||||
describe("chat commercial actions", () => {
|
||||
it("maps the backend gift invitation onto the assistant UI message", () => {
|
||||
const response = ChatSendResponseSchema.parse({
|
||||
reply: "You always know how to make me smile.",
|
||||
messageId: "message-1",
|
||||
timestamp: 1_753_092_000_000,
|
||||
commercialAction: {
|
||||
actionId: "action-1",
|
||||
type: "giftOffer",
|
||||
copy: "A coffee would make this moment even sweeter.",
|
||||
ctaLabel: "Choose a gift",
|
||||
target: "giftCatalog",
|
||||
ruleId: "compliment_30",
|
||||
},
|
||||
});
|
||||
|
||||
const message = sendResponseToUiMessage(response);
|
||||
|
||||
expect(message.commercialAction).toEqual(response.commercialAction);
|
||||
expect(message.isFromAI).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps ordinary chat responses free of commercial actions", () => {
|
||||
const response = ChatSendResponseSchema.parse({ reply: "I'm here." });
|
||||
|
||||
expect(response.commercialAction).toBeNull();
|
||||
expect(sendResponseToUiMessage(response)).not.toHaveProperty(
|
||||
"commercialAction",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CommercialActionSchema = z
|
||||
.object({
|
||||
actionId: z.string().min(1),
|
||||
type: z.literal("giftOffer"),
|
||||
copy: z.string().min(1),
|
||||
ctaLabel: z.string().min(1),
|
||||
target: z.literal("giftCatalog"),
|
||||
ruleId: z.string().min(1),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type CommercialAction = z.output<typeof CommercialActionSchema>;
|
||||
@@ -6,11 +6,14 @@ export * from "./chat_lock_type";
|
||||
export * from "./chat_media";
|
||||
export * from "./chat_message";
|
||||
export * from "./chat_payloads";
|
||||
export * from "./commercial_action";
|
||||
export * from "./request/send_message_request";
|
||||
export * from "./request/tip_offer_action_request";
|
||||
export * from "./request/unlock_history_request";
|
||||
export * from "./request/unlock_private_request";
|
||||
export * from "./response/chat_history_response";
|
||||
export * from "./response/chat_previews_response";
|
||||
export * from "./response/chat_send_response";
|
||||
export * from "./response/tip_offer_action_response";
|
||||
export * from "./response/unlock_history_response";
|
||||
export * from "./response/unlock_private_response";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TipOfferActionRequestSchema = z
|
||||
.object({
|
||||
characterId: z.string().min(1).max(64),
|
||||
sessionId: z.string().min(1).max(64),
|
||||
action: z.enum(["opened", "dismissed"]),
|
||||
triggerSource: z.enum(["manualMenu", "aiContext"]),
|
||||
orderId: z.string().min(1).max(160).optional(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type TipOfferActionRequest = z.output<
|
||||
typeof TipOfferActionRequestSchema
|
||||
>;
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
booleanOrTrue,
|
||||
numberOrLazy,
|
||||
numberOrZero,
|
||||
schemaOrNull,
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
|
||||
import { CommercialActionSchema } from "../commercial_action";
|
||||
|
||||
export const ChatSendResponseSchema = z
|
||||
.object({
|
||||
@@ -27,6 +29,7 @@ export const ChatSendResponseSchema = z
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
commercialAction: schemaOrNull(CommercialActionSchema),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOrEmpty, stringOrNull } from "../../nullable-defaults";
|
||||
|
||||
export const TipOfferActionResponseSchema = z
|
||||
.object({
|
||||
interactionId: stringOrNull,
|
||||
sessionId: z.string().min(1),
|
||||
characterId: z.string().min(1),
|
||||
action: z.enum(["opened", "dismissed"]),
|
||||
message: z
|
||||
.object({
|
||||
messageId: stringOrEmpty,
|
||||
role: z.literal("assistant"),
|
||||
messageType: z.literal("tipOffer"),
|
||||
content: z.string(),
|
||||
createdAt: stringOrNull,
|
||||
})
|
||||
.readonly(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type TipOfferActionResponse = z.output<
|
||||
typeof TipOfferActionResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
booleanOrTrue,
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
} from "../nullable-defaults";
|
||||
|
||||
export const GiftOfferSchema = z
|
||||
.object({
|
||||
characterId: stringOrEmpty,
|
||||
enabled: booleanOrTrue,
|
||||
prompt: stringOrEmpty,
|
||||
declinedMessage: stringOrEmpty,
|
||||
displayMode: z.literal("manual").default("manual"),
|
||||
cooldownSeconds: numberOrZero,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type GiftOffer = z.output<typeof GiftOfferSchema>;
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export * from "./payment_plan";
|
||||
export * from "./gift_category";
|
||||
export * from "./gift_offer";
|
||||
export * from "./gift_product";
|
||||
export * from "./request/create_payment_order_request";
|
||||
export * from "./request/tip_message_request";
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty, stringOrNull } from "../../nullable-defaults";
|
||||
import {
|
||||
arrayOrEmpty,
|
||||
schemaOrNull,
|
||||
stringOrNull,
|
||||
} from "../../nullable-defaults";
|
||||
import { GiftCategorySchema } from "../gift_category";
|
||||
import { GiftOfferSchema } from "../gift_offer";
|
||||
import { GiftProductSchema } from "../gift_product";
|
||||
|
||||
export const GiftProductsResponseSchema = z
|
||||
.object({
|
||||
characterId: stringOrNull,
|
||||
offer: schemaOrNull(GiftOfferSchema),
|
||||
categories: arrayOrEmpty(GiftCategorySchema),
|
||||
plans: arrayOrEmpty(GiftProductSchema),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOrEmpty, stringOrNull } from "../../nullable-defaults";
|
||||
|
||||
export const TipMessageResponseSchema = z
|
||||
.object({
|
||||
orderId: z.string().min(1),
|
||||
@@ -9,6 +11,8 @@ export const TipMessageResponseSchema = z
|
||||
tipCount: z.number().int().positive(),
|
||||
poolIndex: z.number().int().min(0).max(99),
|
||||
message: z.string().min(1),
|
||||
messageId: stringOrEmpty,
|
||||
createdAt: stringOrNull,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
RelationshipDiariesResponseSchema,
|
||||
RelationshipDiaryUnlockResponseSchema,
|
||||
} from "@/data/schemas/private-room";
|
||||
|
||||
describe("relationship diary contracts", () => {
|
||||
it("keeps locked diary payloads free of content and media URLs", () => {
|
||||
const response = RelationshipDiariesResponseSchema.parse({
|
||||
characterId: "maya",
|
||||
unreadCount: 1,
|
||||
creditBalance: 90,
|
||||
currency: "credits",
|
||||
nextCursor: null,
|
||||
items: [
|
||||
{
|
||||
diaryId: "diary-1",
|
||||
characterId: "maya",
|
||||
diaryDate: "2026-07-21",
|
||||
language: "en",
|
||||
title: "A note from today",
|
||||
teaserText: "I kept thinking about our conversation.",
|
||||
unlockCostCredits: 120,
|
||||
locked: true,
|
||||
seen: false,
|
||||
publishedAt: "2026-07-21T11:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.items[0]).not.toHaveProperty("content");
|
||||
expect(response.items[0]).not.toHaveProperty("imageUrl");
|
||||
});
|
||||
|
||||
it("parses an atomic successful unlock with signed media", () => {
|
||||
const response = RelationshipDiaryUnlockResponseSchema.parse({
|
||||
reason: "ok",
|
||||
unlocked: true,
|
||||
creditsCharged: 120,
|
||||
previousCreditBalance: 200,
|
||||
creditBalance: 80,
|
||||
diary: {
|
||||
diaryId: "diary-1",
|
||||
characterId: "maya",
|
||||
diaryDate: "2026-07-21",
|
||||
language: "en",
|
||||
title: "A note from today",
|
||||
teaserText: "I kept thinking about our conversation.",
|
||||
unlockCostCredits: 120,
|
||||
locked: false,
|
||||
seen: true,
|
||||
publishedAt: "2026-07-21T11:00:00Z",
|
||||
content: "This is the private diary body.",
|
||||
imageUrl:
|
||||
"https://demo.supabase.co/storage/v1/object/sign/relationship-diaries/maya/1.jpg?token=test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.reason).toBe("ok");
|
||||
expect(response.diary?.locked).toBe(false);
|
||||
expect(response.diary?.imageUrl).toContain("/object/sign/");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,11 @@
|
||||
*/
|
||||
|
||||
export * from "./private_album";
|
||||
export * from "./relationship_diary";
|
||||
export * from "./request/unlock_private_album_request";
|
||||
export * from "./request/unlock_relationship_diary_request";
|
||||
export * from "./response/private_album_unlock_response";
|
||||
export * from "./response/private_albums_response";
|
||||
export * from "./response/relationship_diaries_response";
|
||||
export * from "./response/relationship_diary_seen_response";
|
||||
export * from "./response/relationship_diary_unlock_response";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
|
||||
export const RelationshipDiarySchema = z
|
||||
.object({
|
||||
diaryId: z.string().min(1),
|
||||
characterId: z.string().min(1),
|
||||
diaryDate: stringOrNull,
|
||||
language: stringOrEmpty,
|
||||
title: stringOrEmpty,
|
||||
teaserText: stringOrEmpty,
|
||||
unlockCostCredits: numberOrZero,
|
||||
locked: booleanOrFalse,
|
||||
seen: booleanOrFalse,
|
||||
publishedAt: stringOrNull,
|
||||
content: z.string().optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type RelationshipDiary = z.output<typeof RelationshipDiarySchema>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UnlockRelationshipDiaryRequestSchema = z
|
||||
.object({
|
||||
expectedCost: z.number().int().positive(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type UnlockRelationshipDiaryRequest = z.output<
|
||||
typeof UnlockRelationshipDiaryRequestSchema
|
||||
>;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
arrayOrEmpty,
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
stringOrNull,
|
||||
} from "../../nullable-defaults";
|
||||
import { RelationshipDiarySchema } from "../relationship_diary";
|
||||
|
||||
export const RelationshipDiariesResponseSchema = z
|
||||
.object({
|
||||
characterId: stringOrEmpty,
|
||||
unreadCount: numberOrZero,
|
||||
creditBalance: numberOrZero,
|
||||
currency: z.literal("credits").default("credits"),
|
||||
items: arrayOrEmpty(RelationshipDiarySchema),
|
||||
nextCursor: stringOrNull,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type RelationshipDiariesResponse = z.output<
|
||||
typeof RelationshipDiariesResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, stringOrNull } from "../../nullable-defaults";
|
||||
|
||||
export const RelationshipDiarySeenResponseSchema = z
|
||||
.object({
|
||||
reason: z.enum(["ok", "not_found"]),
|
||||
seen: booleanOrFalse,
|
||||
seenAt: stringOrNull,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type RelationshipDiarySeenResponse = z.output<
|
||||
typeof RelationshipDiarySeenResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
schemaOrNull,
|
||||
} from "../../nullable-defaults";
|
||||
import { RelationshipDiarySchema } from "../relationship_diary";
|
||||
|
||||
export const RelationshipDiaryUnlockReasonSchema = z.enum([
|
||||
"ok",
|
||||
"already_unlocked",
|
||||
"insufficient_credits",
|
||||
"cost_changed",
|
||||
"not_found",
|
||||
"deduct_failed",
|
||||
]);
|
||||
|
||||
export const RelationshipDiaryUnlockResponseSchema = z
|
||||
.object({
|
||||
reason: RelationshipDiaryUnlockReasonSchema,
|
||||
unlocked: booleanOrFalse,
|
||||
creditsCharged: numberOrZero,
|
||||
creditBalance: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
currentCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
previousCreditBalance: numberOrZero,
|
||||
diary: schemaOrNull(RelationshipDiarySchema),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type RelationshipDiaryUnlockResponse = z.output<
|
||||
typeof RelationshipDiaryUnlockResponseSchema
|
||||
>;
|
||||
@@ -5,8 +5,13 @@ import { ApiPath } from "../api_path";
|
||||
|
||||
describe("ApiPath contract source", () => {
|
||||
it("uses the shared contract path for every static operation", () => {
|
||||
const dynamicOperations = new Set([
|
||||
"privateRoomAlbumUnlock",
|
||||
"privateRoomDiarySeen",
|
||||
"privateRoomDiaryUnlock",
|
||||
]);
|
||||
const staticOperations = Object.entries(apiContract).filter(
|
||||
([operationId]) => operationId !== "privateRoomAlbumUnlock",
|
||||
([operationId]) => !dynamicOperations.has(operationId),
|
||||
);
|
||||
|
||||
for (const [operationId, operation] of staticOperations) {
|
||||
@@ -21,4 +26,13 @@ describe("ApiPath contract source", () => {
|
||||
"/api/private-room/albums/album%2Fid%201/unlock",
|
||||
);
|
||||
});
|
||||
|
||||
it("fills and encodes relationship diary path parameters", () => {
|
||||
expect(ApiPath.privateRoomDiarySeen("diary/id 1")).toBe(
|
||||
"/api/private-room/diaries/diary%2Fid%201/seen",
|
||||
);
|
||||
expect(ApiPath.privateRoomDiaryUnlock("diary/id 1")).toBe(
|
||||
"/api/private-room/diaries/diary%2Fid%201/unlock",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,15 @@
|
||||
"paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" },
|
||||
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
|
||||
"chatSend": { "method": "post", "path": "/api/chat/send" },
|
||||
"chatTipOfferActions": { "method": "post", "path": "/api/chat/tip-offer-actions" },
|
||||
"chatHistory": { "method": "get", "path": "/api/chat/history" },
|
||||
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
|
||||
"chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" },
|
||||
"privateRoomAlbums": { "method": "get", "path": "/api/private-room/albums" },
|
||||
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
|
||||
"privateRoomDiaries": { "method": "get", "path": "/api/private-room/diaries" },
|
||||
"privateRoomDiarySeen": { "method": "post", "path": "/api/private-room/diaries/{diaryId}/seen" },
|
||||
"privateRoomDiaryUnlock": { "method": "post", "path": "/api/private-room/diaries/{diaryId}/unlock" },
|
||||
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
|
||||
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
|
||||
"feedback": { "method": "post", "path": "/api/feedback" },
|
||||
|
||||
@@ -70,6 +70,9 @@ export class ApiPath {
|
||||
/** 发送消息 */
|
||||
static readonly chatSend = apiContract.chatSend.path;
|
||||
|
||||
/** 记录礼物邀请打开或取消动作 */
|
||||
static readonly chatTipOfferActions = apiContract.chatTipOfferActions.path;
|
||||
|
||||
/** 获取聊天历史 */
|
||||
static readonly chatHistory = apiContract.chatHistory.path;
|
||||
|
||||
@@ -91,6 +94,25 @@ export class ApiPath {
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取当前角色的专属日记 */
|
||||
static readonly privateRoomDiaries = apiContract.privateRoomDiaries.path;
|
||||
|
||||
/** 将专属日记标记为已读 */
|
||||
static privateRoomDiarySeen(diaryId: string): string {
|
||||
return apiContract.privateRoomDiarySeen.path.replace(
|
||||
"{diaryId}",
|
||||
encodeURIComponent(diaryId),
|
||||
);
|
||||
}
|
||||
|
||||
/** 使用积分解锁专属日记 */
|
||||
static privateRoomDiaryUnlock(diaryId: string): string {
|
||||
return apiContract.privateRoomDiaryUnlock.path.replace(
|
||||
"{diaryId}",
|
||||
encodeURIComponent(diaryId),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 数据看板相关 ============
|
||||
/** 上报 PWA 事件 */
|
||||
static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path;
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
ChatSendResponse,
|
||||
ChatSendResponseSchema,
|
||||
SendMessageRequest,
|
||||
TipOfferActionRequest,
|
||||
TipOfferActionRequestSchema,
|
||||
TipOfferActionResponse,
|
||||
TipOfferActionResponseSchema,
|
||||
UnlockHistoryRequest,
|
||||
UnlockHistoryResponse,
|
||||
UnlockHistoryResponseSchema,
|
||||
@@ -39,6 +43,19 @@ export class ChatApi {
|
||||
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async recordTipOfferAction(
|
||||
body: TipOfferActionRequest,
|
||||
): Promise<TipOfferActionResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.chatTipOfferActions,
|
||||
{
|
||||
method: "POST",
|
||||
body: TipOfferActionRequestSchema.parse(body),
|
||||
},
|
||||
);
|
||||
return TipOfferActionResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,13 @@ import {
|
||||
PrivateAlbumUnlockResponse,
|
||||
PrivateAlbumUnlockResponseSchema,
|
||||
UnlockPrivateAlbumRequest,
|
||||
RelationshipDiariesResponse,
|
||||
RelationshipDiariesResponseSchema,
|
||||
RelationshipDiarySeenResponse,
|
||||
RelationshipDiarySeenResponseSchema,
|
||||
RelationshipDiaryUnlockResponse,
|
||||
RelationshipDiaryUnlockResponseSchema,
|
||||
UnlockRelationshipDiaryRequest,
|
||||
} from "@/data/schemas/private-room";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
@@ -15,6 +22,12 @@ export interface GetPrivateAlbumsInput {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GetRelationshipDiariesInput {
|
||||
characterId: string;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
}
|
||||
|
||||
export class PrivateRoomApi {
|
||||
async getAlbums(
|
||||
input: GetPrivateAlbumsInput,
|
||||
@@ -44,6 +57,46 @@ export class PrivateRoomApi {
|
||||
);
|
||||
return PrivateAlbumUnlockResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
|
||||
async getDiaries(
|
||||
input: GetRelationshipDiariesInput,
|
||||
): Promise<RelationshipDiariesResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.privateRoomDiaries,
|
||||
{
|
||||
query: {
|
||||
characterId: input.characterId,
|
||||
limit: input.limit ?? 20,
|
||||
...(input.cursor ? { cursor: input.cursor } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
return RelationshipDiariesResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
|
||||
async markDiarySeen(
|
||||
diaryId: string,
|
||||
): Promise<RelationshipDiarySeenResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.privateRoomDiarySeen(diaryId),
|
||||
{ method: "POST" },
|
||||
);
|
||||
return RelationshipDiarySeenResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
|
||||
async unlockDiary(
|
||||
diaryId: string,
|
||||
body: UnlockRelationshipDiaryRequest,
|
||||
): Promise<RelationshipDiaryUnlockResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.privateRoomDiaryUnlock(diaryId),
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
},
|
||||
);
|
||||
return RelationshipDiaryUnlockResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
}
|
||||
|
||||
export const privateRoomApi = new PrivateRoomApi();
|
||||
|
||||
@@ -5,7 +5,10 @@ import memoryDriver from "unstorage/drivers/memory";
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { SessionAsyncUtil } from "@/utils/session-storage";
|
||||
|
||||
import { NavigationStorage } from "../navigation_storage";
|
||||
import {
|
||||
createPendingChatPromotion,
|
||||
NavigationStorage,
|
||||
} from "../navigation_storage";
|
||||
|
||||
const CHARACTER_ID = "elio";
|
||||
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 () => {
|
||||
await NavigationStorage.savePendingChatImageReturn({
|
||||
characterId: CHARACTER_ID,
|
||||
|
||||
@@ -69,6 +69,19 @@ export type PendingChatImageReturn = z.output<
|
||||
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.
|
||||
*
|
||||
@@ -180,13 +193,10 @@ export class NavigationStorage {
|
||||
promotionType: PendingChatPromotionType,
|
||||
characterId: string,
|
||||
): Promise<PendingChatPromotion> {
|
||||
const promotion: PendingChatPromotion = {
|
||||
characterId,
|
||||
const promotion = createPendingChatPromotion(
|
||||
promotionType,
|
||||
lockType: toPromotionLockType(promotionType),
|
||||
clientLockId: `promotion_${createUuidV4()}`,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
characterId,
|
||||
);
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatPromotion,
|
||||
promotion,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TipOfferActionRequest } from "@/data/schemas/chat";
|
||||
import { chatApi } from "@/data/services/api/chat_api";
|
||||
|
||||
const SESSION_KEY_PREFIX = "cozsweet.tipOffer.session";
|
||||
|
||||
export function getTipOfferSessionId(characterId: string): string {
|
||||
const key = `${SESSION_KEY_PREFIX}.${characterId}`;
|
||||
try {
|
||||
const existing = globalThis.sessionStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const created = createSessionId();
|
||||
globalThis.sessionStorage.setItem(key, created);
|
||||
return created;
|
||||
} catch {
|
||||
return createSessionId();
|
||||
}
|
||||
}
|
||||
|
||||
export function recordTipOfferAction(
|
||||
characterId: string,
|
||||
action: TipOfferActionRequest["action"],
|
||||
triggerSource: TipOfferActionRequest["triggerSource"],
|
||||
): void {
|
||||
void chatApi
|
||||
.recordTipOfferAction({
|
||||
characterId,
|
||||
sessionId: getTipOfferSessionId(characterId),
|
||||
action,
|
||||
triggerSource,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function createSessionId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createPendingChatPromotion as createPromotion,
|
||||
NavigationStorage,
|
||||
type PendingChatUnlock,
|
||||
type PendingChatUnlockKind,
|
||||
@@ -17,6 +18,13 @@ export type {
|
||||
PendingChatUnlockStage,
|
||||
};
|
||||
|
||||
export function createPendingChatPromotion(
|
||||
promotionType: PendingChatPromotionType,
|
||||
characterId: string,
|
||||
): PendingChatPromotion {
|
||||
return createPromotion(promotionType, characterId);
|
||||
}
|
||||
|
||||
export async function savePendingChatUnlock(input: {
|
||||
characterId: string;
|
||||
displayMessageId?: string;
|
||||
|
||||
@@ -75,6 +75,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
? { audioUrl: response.audioUrl }
|
||||
: {}),
|
||||
...deriveUiLockFields(response.lockDetail, response.image.url),
|
||||
...(response.commercialAction
|
||||
? { commercialAction: response.commercialAction }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CommercialActionSchema } from "@/data/schemas/chat";
|
||||
|
||||
export const UiMessageSchema = z.object({
|
||||
displayId: z.string().min(1),
|
||||
remoteId: z.string().min(1).optional(),
|
||||
@@ -16,6 +18,7 @@ export const UiMessageSchema = z.object({
|
||||
isPrivate: z.boolean().nullable().optional(),
|
||||
lockedPrivate: z.boolean().nullable().optional(),
|
||||
privateMessageHint: z.string().nullable().optional(),
|
||||
commercialAction: CommercialActionSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export type UiMessage = z.infer<typeof UiMessageSchema>;
|
||||
|
||||
@@ -96,6 +96,8 @@ describe("payment order flow", () => {
|
||||
tipCount: 2,
|
||||
poolIndex: 18,
|
||||
message: "You made my day.",
|
||||
messageId: "message-tip-2",
|
||||
createdAt: "2026-07-21T10:00:00Z",
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
@@ -37,6 +37,7 @@ export function hydrateGiftProductsState(
|
||||
PaymentState,
|
||||
| "plans"
|
||||
| "giftCategories"
|
||||
| "giftOffer"
|
||||
| "giftProducts"
|
||||
| "selectedGiftCategory"
|
||||
| "selectedPlanId"
|
||||
@@ -66,6 +67,7 @@ export function hydrateGiftProductsState(
|
||||
return {
|
||||
plans: visibleProducts.map(giftProductToPaymentPlan),
|
||||
giftCategories,
|
||||
giftOffer: response.offer,
|
||||
giftProducts,
|
||||
selectedGiftCategory,
|
||||
selectedPlanId,
|
||||
|
||||
@@ -49,6 +49,7 @@ function initializeCatalogState(
|
||||
...resetOrderState(),
|
||||
plans: [],
|
||||
giftCategories: [],
|
||||
giftOffer: null,
|
||||
giftProducts: [],
|
||||
selectedGiftCategory: null,
|
||||
selectedPlanId: "",
|
||||
@@ -181,6 +182,7 @@ const applyPlansErrorAction = plansErrorSetup.assign(({ event }) => ({
|
||||
const applyGiftProductsErrorAction = plansErrorSetup.assign(({ event }) => ({
|
||||
plans: [],
|
||||
giftCategories: [],
|
||||
giftOffer: null,
|
||||
giftProducts: [],
|
||||
selectedGiftCategory: null,
|
||||
selectedPlanId: "",
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface PaymentContextState {
|
||||
planCatalog: MachineContext["planCatalog"];
|
||||
plans: MachineContext["plans"];
|
||||
giftCategories: MachineContext["giftCategories"];
|
||||
giftOffer: MachineContext["giftOffer"];
|
||||
giftProducts: MachineContext["giftProducts"];
|
||||
selectedGiftCategory: string | null;
|
||||
isFirstRecharge: MachineContext["isFirstRecharge"];
|
||||
@@ -71,6 +72,7 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
|
||||
planCatalog: state.context.planCatalog,
|
||||
plans: state.context.plans,
|
||||
giftCategories: state.context.giftCategories,
|
||||
giftOffer: state.context.giftOffer,
|
||||
giftProducts: state.context.giftProducts,
|
||||
selectedGiftCategory: state.context.selectedGiftCategory,
|
||||
isFirstRecharge: state.context.isFirstRecharge,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Context shared by the default and Tip payment catalogs. */
|
||||
import type {
|
||||
GiftCategory,
|
||||
GiftOffer,
|
||||
GiftProduct,
|
||||
PayChannel,
|
||||
PaymentOrderStatus,
|
||||
@@ -14,6 +15,7 @@ export interface PaymentState {
|
||||
planCatalog: PaymentPlanCatalog;
|
||||
plans: readonly PaymentPlan[];
|
||||
giftCategories: readonly GiftCategory[];
|
||||
giftOffer: GiftOffer | null;
|
||||
giftProducts: readonly GiftProduct[];
|
||||
giftCharacterId: string | null;
|
||||
selectedGiftCategory: string | null;
|
||||
@@ -39,6 +41,7 @@ export const initialState: PaymentState = {
|
||||
planCatalog: "default",
|
||||
plans: [],
|
||||
giftCategories: [],
|
||||
giftOffer: null,
|
||||
giftProducts: [],
|
||||
giftCharacterId: null,
|
||||
selectedGiftCategory: null,
|
||||
|
||||
Reference in New Issue
Block a user