feat(chat): render commercial actions and persist greetings
Docker Image / Build and Push Docker Image (push) Successful in 1m56s

This commit is contained in:
Codex
2026-07-23 14:52:34 +08:00
parent 79397af739
commit 02f6964484
26 changed files with 548 additions and 1 deletions
+36
View File
@@ -18,6 +18,8 @@ import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session
import { buildGlobalPageUrl } from "@/router/global-route-context";
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
import type { CommercialAction } from "@/data/schemas/chat";
import { behaviorAnalytics } from "@/lib/analytics";
import { MobileShell } from "@/app/_components/core";
@@ -250,6 +252,38 @@ export function ChatScreen() {
router.push(characterRoutes.privateZone);
}
function handleCommercialAction(action: CommercialAction): void {
behaviorAnalytics.elementClick(
"chat.commercial_action.open",
action.ctaLabel,
{
actionId: action.actionId,
actionType: action.type,
characterId: state.characterId,
ruleId: action.ruleId,
target: action.target,
},
);
router.push(
action.target === "giftCatalog"
? characterRoutes.tip
: characterRoutes.privateZone,
);
}
function handleCommercialActionDismiss(action: CommercialAction): void {
behaviorAnalytics.elementClick(
"chat.commercial_action.dismiss",
"Dismiss commercial action",
{
actionId: action.actionId,
actionType: action.type,
characterId: state.characterId,
ruleId: action.ruleId,
},
);
}
return (
<MobileShell>
<div
@@ -298,6 +332,8 @@ export function ChatScreen() {
onOpenImage={handleOpenImage}
onUserAvatarClick={handleOpenUserProfile}
onCharacterAvatarClick={handleOpenCharacterPrivateZone}
onCommercialAction={handleCommercialAction}
onCommercialActionDismiss={handleCommercialActionDismiss}
onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}}
@@ -0,0 +1,26 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { CommercialActionCard } from "../commercial-action-card";
describe("CommercialActionCard", () => {
it("renders a private-zone offer with a usable CTA", () => {
const html = renderToStaticMarkup(
<CommercialActionCard
action={{
actionId: "action-1",
type: "privateAlbumOffer",
copy: "There are more private photos waiting for you.",
ctaLabel: "Open private zone",
target: "privateZone",
ruleId: "private_album_after_appearance_praise",
}}
/>,
);
expect(html).toContain('data-commercial-action="privateAlbumOffer"');
expect(html).toContain("There are more private photos waiting for you.");
expect(html).toContain("Open private zone");
expect(html).toContain('aria-label="Dismiss offer"');
});
});
@@ -131,6 +131,64 @@
align-items: flex-end;
}
.commercialAction {
width: min(100%, 286px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(25, 21, 31, 0.92);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
}
.commercialActionHeader {
display: flex;
min-height: 24px;
align-items: center;
justify-content: space-between;
color: #f4b860;
}
.commercialActionDismiss {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.66);
cursor: pointer;
}
.commercialActionCopy {
margin: 7px 0 11px;
color: rgba(255, 255, 255, 0.9);
font-size: var(--responsive-caption, var(--font-size-sm, 13px));
line-height: 1.45;
}
.commercialActionButton {
display: inline-flex;
min-height: 38px;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 12px;
border: 0;
border-radius: 6px;
background: #f4b860;
color: #201710;
cursor: pointer;
font-size: var(--responsive-caption, var(--font-size-sm, 13px));
font-weight: 700;
}
.commercialActionButton span {
min-width: 0;
overflow-wrap: anywhere;
}
.bubbleAi {
max-width: var(--chat-bubble-max-width, 75%);
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
+12
View File
@@ -24,6 +24,7 @@ import {
import { LoaderCircle } from "lucide-react";
import type { UiMessage } from "@/stores/chat/ui-message";
import type { CommercialAction } from "@/data/schemas/chat";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import {
@@ -61,6 +62,8 @@ export interface ChatAreaProps {
onLoadMoreHistory?: () => void;
onUserAvatarClick?: () => void;
onCharacterAvatarClick?: () => void;
onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void;
}
type ChatMessageAction = (
@@ -85,6 +88,8 @@ export function ChatArea({
onLoadMoreHistory,
onUserAvatarClick,
onCharacterAvatarClick,
onCommercialAction,
onCommercialActionDismiss,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -257,6 +262,8 @@ export function ChatArea({
onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
onCommercialAction,
onCommercialActionDismiss,
)}
{isReplyingAI ? (
@@ -295,6 +302,8 @@ function renderMessagesWithDateHeaders(
onOpenImage?: (displayMessageId: string) => void,
onUserAvatarClick?: () => void,
onCharacterAvatarClick?: () => void,
onCommercialAction?: (action: CommercialAction) => void,
onCommercialActionDismiss?: (action: CommercialAction) => void,
) {
return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? (
@@ -314,6 +323,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
@@ -324,6 +334,8 @@ function renderMessagesWithDateHeaders(
onOpenImage={onOpenImage}
onUserAvatarClick={onUserAvatarClick}
onCharacterAvatarClick={onCharacterAvatarClick}
onCommercialAction={onCommercialAction}
onCommercialActionDismiss={onCommercialActionDismiss}
/>
),
);
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { ArrowRight, Gift, Images, X } from "lucide-react";
import type { CommercialAction } from "@/data/schemas/chat";
import styles from "./chat-area.module.css";
export interface CommercialActionCardProps {
action: CommercialAction;
onActivate?: (action: CommercialAction) => void;
onDismiss?: (action: CommercialAction) => void;
}
export function CommercialActionCard({
action,
onActivate,
onDismiss,
}: CommercialActionCardProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
const Icon = action.type === "giftOffer" ? Gift : Images;
return (
<aside
className={styles.commercialAction}
aria-label={action.ctaLabel}
data-commercial-action={action.type}
>
<div className={styles.commercialActionHeader}>
<Icon size={18} aria-hidden="true" />
<button
type="button"
className={styles.commercialActionDismiss}
title="Dismiss offer"
aria-label="Dismiss offer"
onClick={() => {
setDismissed(true);
onDismiss?.(action);
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className={styles.commercialActionCopy}>{action.copy}</p>
<button
type="button"
className={styles.commercialActionButton}
onClick={() => onActivate?.(action)}
>
<span>{action.ctaLabel}</span>
<ArrowRight size={17} aria-hidden="true" />
</button>
</aside>
);
}
+1
View File
@@ -5,6 +5,7 @@
export * from "./ai-disclosure-banner";
export * from "./browser-hint-overlay";
export * from "./chat-area";
export * from "./commercial-action-card";
export * from "./chat-header";
export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar";
@@ -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";
@@ -27,6 +28,7 @@ export interface MessageBubbleProps {
lockReason?: string | null;
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
commercialAction?: CommercialAction | null;
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
@@ -34,6 +36,8 @@ export interface MessageBubbleProps {
onOpenImage?: (displayMessageId: string) => void;
onUserAvatarClick?: () => void;
onCharacterAvatarClick?: () => void;
onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void;
}
type ChatMessageAction = (
@@ -54,6 +58,7 @@ export function MessageBubble({
lockReason,
lockedPrivate,
privateMessageHint,
commercialAction,
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
@@ -61,6 +66,8 @@ export function MessageBubble({
onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
onCommercialAction,
onCommercialActionDismiss,
}: MessageBubbleProps) {
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
@@ -89,11 +96,14 @@ export function MessageBubble({
lockReason={lockReason}
lockedPrivate={lockedPrivate}
privateMessageHint={privateMessageHint}
commercialAction={commercialAction}
isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage}
onCommercialAction={onCommercialAction}
onCommercialActionDismiss={onCommercialActionDismiss}
/>
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div>
@@ -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";
@@ -19,11 +22,14 @@ export interface MessageContentProps {
lockReason?: string | null;
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
commercialAction?: CommercialAction | null;
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void;
onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void;
}
type ChatMessageAction = (
@@ -46,11 +52,14 @@ export function MessageContent({
lockReason,
lockedPrivate,
privateMessageHint,
commercialAction,
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
onCommercialAction,
onCommercialActionDismiss,
}: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0;
const hasAudio = audioUrl != null && audioUrl.length > 0;
@@ -125,6 +134,13 @@ export function MessageContent({
) : null}
</>
)}
{isFromAI && commercialAction ? (
<CommercialActionCard
action={commercialAction}
onActivate={onCommercialAction}
onDismiss={onCommercialActionDismiss}
/>
) : null}
</div>
);
}