feat(chat): add promotional external entry flow

This commit is contained in:
2026-07-13 12:43:18 +08:00
parent e5ee9940ca
commit 3752b3b729
44 changed files with 1623 additions and 190 deletions
+62 -5
View File
@@ -34,6 +34,7 @@ import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-ba
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
import styles from "./components/chat-screen.module.css";
export function ChatScreen() {
@@ -42,6 +43,10 @@ export function ChatScreen() {
const state = useChatState();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const isPromotionBootstrapReady = useChatPromotionBootstrap();
const visibleMessages = isPromotionBootstrapReady
? state.messages
: state.historyMessages;
const imageMessageId = getChatImageOverlayMessageId(searchParams);
const imageReturnUrl = imageMessageId
? buildChatImageOverlayUrl(imageMessageId)
@@ -54,6 +59,8 @@ export function ChatScreen() {
} = useChatUnlockNavigationFlow({
returnUrl: ROUTES.chat,
ignoredKind: "image",
promotionScope: "exclude",
enabled: isPromotionBootstrapReady,
});
const {
unlockPaywallRequest: imageUnlockPaywallRequest,
@@ -64,16 +71,28 @@ export function ChatScreen() {
returnUrl: imageReturnUrl,
expectedKind: "image",
expectedMessageId: imageMessageId ?? undefined,
enabled: imageMessageId !== null,
promotionScope: "exclude",
enabled: isPromotionBootstrapReady && imageMessageId !== null,
});
const {
unlockPaywallRequest: promotionUnlockPaywallRequest,
requestMessageUnlock: requestPromotionUnlock,
closeInsufficientCreditsDialog: closePromotionInsufficientCreditsDialog,
confirmInsufficientCreditsDialog:
confirmPromotionInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({
returnUrl: ROUTES.chat,
promotionScope: "only",
enabled: isPromotionBootstrapReady && state.promotion !== null,
});
const selectedImageMessage = useMemo(
() =>
imageMessageId
? state.messages.find(
? visibleMessages.find(
(item) => item.id === imageMessageId && item.imageUrl,
) ?? null
: null,
[imageMessageId, state.messages],
[imageMessageId, visibleMessages],
);
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
@@ -89,7 +108,8 @@ export function ChatScreen() {
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
});
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
const shouldShowPwaInstall =
state.historyLoaded && state.historyMessages.length >= 10;
const shouldShowBrowserHint = shouldStartExternalBrowserPrompt({
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
@@ -119,13 +139,38 @@ export function ChatScreen() {
]);
function handleUnlockPrivateMessage(messageId: string): void {
if (requestPromotionMessageUnlock(messageId, "private")) return;
requestMessageUnlock(messageId, "private");
}
function handleUnlockVoiceMessage(messageId: string): void {
if (requestPromotionMessageUnlock(messageId, "voice")) return;
requestMessageUnlock(messageId, "voice");
}
function handleUnlockImageMessage(messageId: string): void {
if (requestPromotionMessageUnlock(messageId, "image")) return;
requestMessageUnlock(messageId, "image");
}
function requestPromotionMessageUnlock(
messageId: string,
kind: "private" | "voice" | "image",
): boolean {
const promotion = state.promotion;
if (!promotion || promotion.message.id !== messageId) return false;
const temporaryMessageId = `promotion:${promotion.session.clientLockId}`;
requestPromotionUnlock(messageId, kind, {
remoteMessageId:
messageId === temporaryMessageId ? undefined : messageId,
lockType: promotion.session.lockType,
clientLockId: promotion.session.clientLockId,
promotion: promotion.session,
});
return true;
}
function handleOpenImage(messageId: string): void {
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
}
@@ -169,12 +214,13 @@ export function ChatScreen() {
/>
<ChatArea
messages={state.messages}
messages={visibleMessages}
isReplyingAI={state.isReplyingAI}
isUnlockingMessage={state.isUnlockingMessage}
unlockingMessageId={state.unlockingMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockVoiceMessage={handleUnlockVoiceMessage}
onUnlockImageMessage={handleUnlockImageMessage}
onOpenImage={handleOpenImage}
/>
@@ -201,8 +247,19 @@ export function ChatScreen() {
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
/>
<ChatUnlockDialogs
unlockPaywallRequest={promotionUnlockPaywallRequest}
includeHistoryUnlock={false}
onCloseInsufficientCreditsDialog={
closePromotionInsufficientCreditsDialog
}
onConfirmInsufficientCreditsDialog={
confirmPromotionInsufficientCreditsDialog
}
/>
<ChatUnlockDialogs
unlockPaywallRequest={imageUnlockPaywallRequest}
includeHistoryUnlock={false}
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={
confirmImageInsufficientCreditsDialog
@@ -0,0 +1,20 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { LockedImageMessageCard } from "../locked-image-message-card";
describe("LockedImageMessageCard", () => {
it("renders the promotion hint and an enabled unlock action", () => {
const html = renderToStaticMarkup(
<LockedImageMessageCard
hint="Unlock this private photo."
onUnlock={() => undefined}
/>,
);
expect(html).toContain('aria-label="Locked private image"');
expect(html).toContain("Unlock this private photo.");
expect(html).toContain("Unlock private image");
expect(html).not.toContain(' disabled=""');
});
});
+5
View File
@@ -36,6 +36,7 @@ export interface ChatAreaProps {
unlockingMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
}
@@ -46,6 +47,7 @@ export function ChatArea({
unlockingMessageId,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
@@ -98,6 +100,7 @@ export function ChatArea({
unlockingMessageId,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
)}
@@ -120,6 +123,7 @@ function renderMessagesWithDateHeaders(
unlockingMessageId?: string | null,
onUnlockPrivateMessage?: (messageId: string) => void,
onUnlockVoiceMessage?: (messageId: string) => void,
onUnlockImageMessage?: (messageId: string) => void,
onOpenImage?: (messageId: string) => void,
) {
return buildChatRenderItems(messages, getMessageKey).map((item) =>
@@ -144,6 +148,7 @@ function renderMessagesWithDateHeaders(
}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage}
/>
),
@@ -10,26 +10,30 @@ export interface ChatUnlockDialogsProps {
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
onCloseInsufficientCreditsDialog: () => void;
onConfirmInsufficientCreditsDialog: () => void;
includeHistoryUnlock?: boolean;
}
export function ChatUnlockDialogs({
unlockPaywallRequest,
onCloseInsufficientCreditsDialog,
onConfirmInsufficientCreditsDialog,
includeHistoryUnlock = true,
}: ChatUnlockDialogsProps) {
const chatState = useChatState();
const chatDispatch = useChatDispatch();
return (
<>
<HistoryUnlockDialog
open={chatState.unlockHistoryPromptVisible}
lockedCount={chatState.lockedHistoryCount}
isLoading={chatState.isUnlockingHistory}
errorMessage={chatState.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
{includeHistoryUnlock ? (
<HistoryUnlockDialog
open={chatState.unlockHistoryPromptVisible}
lockedCount={chatState.lockedHistoryCount}
isLoading={chatState.isUnlockingHistory}
errorMessage={chatState.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
) : null}
<InsufficientCreditsDialog
open={unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
@@ -0,0 +1,45 @@
"use client";
import { ImageIcon, LockKeyhole } from "lucide-react";
export interface LockedImageMessageCardProps {
hint?: string | null;
isUnlocking?: boolean;
onUnlock?: () => void;
}
export function LockedImageMessageCard({
hint,
isUnlocking = false,
onUnlock,
}: LockedImageMessageCardProps) {
return (
<div
className="w-[min(72vw,280px)] overflow-hidden rounded-[var(--responsive-card-radius-sm,18px)] rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-white shadow-[0_4px_14px_rgba(246,87,160,0.14)]"
role="group"
aria-label="Locked private image"
>
<div className="relative flex h-32 items-center justify-center bg-[linear-gradient(145deg,#ffeaf3,#fff7fb)] text-[#f657a0]">
<ImageIcon size={46} strokeWidth={1.5} aria-hidden="true" />
<span className="absolute flex size-10 items-center justify-center rounded-full bg-white/90 shadow-sm">
<LockKeyhole size={19} aria-hidden="true" />
</span>
</div>
<div className="p-[var(--responsive-card-padding,14px)]">
<p className="m-0 text-[var(--responsive-body,14px)] leading-[1.4] text-[#3c3b3b]">
{hint && hint.length > 0
? hint
: "Elio sent you a locked image."}
</p>
<button
type="button"
className="mt-[var(--spacing-md,12px)] w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(90deg,#ff67e0,#ff52a2)] px-[var(--spacing-md,12px)] py-[clamp(9px,1.852vw,10px)] text-[var(--responsive-body,14px)] font-bold text-white disabled:cursor-not-allowed disabled:opacity-65 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
>
{isUnlocking ? "Unlocking..." : "Unlock private image"}
</button>
</div>
</div>
);
}
@@ -28,6 +28,7 @@ export interface MessageBubbleProps {
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
}
@@ -45,6 +46,7 @@ export function MessageBubble({
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
}: MessageBubbleProps) {
const { avatarUrl } = useUserState();
@@ -72,6 +74,7 @@ export function MessageBubble({
isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage}
/>
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
@@ -100,6 +103,7 @@ export function MessageBubble({
isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage}
/>
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
@@ -1,5 +1,6 @@
"use client";
import { ImageBubble } from "./image-bubble";
import { LockedImageMessageCard } from "./locked-image-message-card";
import { PrivateMessageCard } from "./private-message-card";
import { TextBubble } from "./text-bubble";
import { VoiceBubble } from "./voice-bubble";
@@ -19,6 +20,7 @@ export interface MessageContentProps {
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
}
@@ -38,6 +40,7 @@ export function MessageContent({
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
}: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0;
@@ -47,6 +50,9 @@ export function MessageContent({
lockedPrivate === true ||
(locked === true && lockReason === "private_message");
const isLockedVoiceMessage = locked === true && lockReason === "voice_message";
const isLockedImageMessage =
locked === true &&
(lockReason === "image_paywall" || lockReason === "image");
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
const handleUnlockPrivateMessage =
messageId && onUnlockPrivateMessage
@@ -56,6 +62,10 @@ export function MessageContent({
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
? () => onUnlockVoiceMessage(messageId)
: undefined;
const handleUnlockImageMessage =
isLockedImageMessage && messageId && onUnlockImageMessage
? () => onUnlockImageMessage(messageId)
: undefined;
return (
<div
@@ -70,6 +80,12 @@ export function MessageContent({
isUnlocking={isUnlockingMessage}
onUnlock={handleUnlockPrivateMessage}
/>
) : isLockedImageMessage && !hasImage ? (
<LockedImageMessageCard
hint={privateMessageHint}
isUnlocking={isUnlockingMessage}
onUnlock={handleUnlockImageMessage}
/>
) : (
<>
{hasImage && imageUrl && (
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
consumePendingChatPromotion,
peekPendingChatUnlock,
} from "@/lib/navigation/chat_unlock_session";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { Logger } from "@/utils";
const log = new Logger("UseChatPromotionBootstrap");
export function useChatPromotionBootstrap(): boolean {
const chatDispatch = useChatDispatch();
const startedRef = useRef(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
void (async () => {
try {
const [entryPromotion, pendingUnlock] = await Promise.all([
consumePendingChatPromotion(),
peekPendingChatUnlock(),
]);
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
if (promotion) {
chatDispatch({
type: "ChatPromotionInjected",
promotion,
messageId: pendingUnlock?.promotion
? pendingUnlock.messageId
: undefined,
});
} else {
chatDispatch({ type: "ChatPromotionCleared" });
}
} catch (error) {
log.warn("Failed to restore chat promotion", error);
chatDispatch({ type: "ChatPromotionCleared" });
} finally {
setIsReady(true);
}
})();
}, [chatDispatch]);
return isReady;
}
@@ -2,11 +2,13 @@
import { useEffect } from "react";
import type { ChatLockType } from "@/data/schemas/chat";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
type PendingChatUnlock,
type PendingChatUnlockKind,
type PendingChatPromotion,
} from "@/lib/navigation/chat_unlock_session";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
@@ -15,11 +17,21 @@ import { useUserState } from "@/stores/user/user-context";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
type PromotionScope = "any" | "only" | "exclude";
export interface MessageUnlockOptions {
remoteMessageId?: string;
lockType?: ChatLockType;
clientLockId?: string;
promotion?: PendingChatPromotion;
}
export interface UseChatUnlockNavigationFlowInput {
returnUrl: string;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
ignoredKind?: PendingChatUnlockKind;
promotionScope?: PromotionScope;
enabled?: boolean;
}
@@ -28,6 +40,7 @@ export interface UseChatUnlockNavigationFlowOutput {
requestMessageUnlock: (
messageId: string,
kind: PendingChatUnlockKind,
options?: MessageUnlockOptions,
) => void;
closeInsufficientCreditsDialog: () => void;
confirmInsufficientCreditsDialog: () => void;
@@ -38,6 +51,7 @@ export function useChatUnlockNavigationFlow({
expectedKind,
expectedMessageId,
ignoredKind,
promotionScope = "any",
enabled = true,
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const navigator = useAppNavigator();
@@ -49,6 +63,7 @@ export function useChatUnlockNavigationFlow({
expectedKind,
expectedMessageId,
ignoredKind,
promotionScope,
enabled,
});
@@ -68,6 +83,7 @@ export function useChatUnlockNavigationFlow({
pending,
expectedKind,
expectedMessageId,
promotionScope,
})
) {
return;
@@ -78,8 +94,11 @@ export function useChatUnlockNavigationFlow({
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
messageId: consumed.displayMessageId,
remoteMessageId: consumed.messageId,
kind: consumed.kind,
lockType: consumed.lockType,
clientLockId: consumed.clientLockId,
});
};
@@ -93,24 +112,34 @@ export function useChatUnlockNavigationFlow({
enabled,
expectedKind,
expectedMessageId,
ignoredKind,
navigator.isAuthenticatedUser,
promotionScope,
returnUrl,
]);
function requestMessageUnlock(
messageId: string,
kind: PendingChatUnlockKind,
options: MessageUnlockOptions = {},
): void {
const remoteMessageId =
options.remoteMessageId ?? (options.lockType ? undefined : messageId);
navigator.startMessageUnlock({
messageId,
displayMessageId: messageId,
messageId: remoteMessageId,
kind,
lockType: options.lockType,
clientLockId: options.clientLockId,
promotion: options.promotion,
returnUrl,
onAuthenticated: () => {
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
remoteMessageId,
kind,
lockType: options.lockType,
clientLockId: options.clientLockId,
});
},
});
@@ -125,8 +154,12 @@ export function useChatUnlockNavigationFlow({
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
navigator.openSubscriptionForPendingUnlock({
displayMessageId: unlockPaywallRequest.displayMessageId,
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
lockType: unlockPaywallRequest.lockType,
clientLockId: unlockPaywallRequest.clientLockId,
promotion: unlockPaywallRequest.promotion,
returnUrl,
type: getInsufficientCreditsSubscriptionType(userState.isVip),
});
@@ -145,6 +178,7 @@ function getScopedUnlockPaywallRequest(input: {
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
ignoredKind?: PendingChatUnlockKind;
promotionScope?: PromotionScope;
enabled?: boolean;
}): ChatUnlockPaywallRequest | null {
const {
@@ -152,13 +186,21 @@ function getScopedUnlockPaywallRequest(input: {
expectedKind,
expectedMessageId,
ignoredKind,
promotionScope = "any",
enabled = true,
} = input;
if (!enabled) return null;
if (!request) return null;
if (!enabled || !request) return null;
if (promotionScope === "only" && !request.promotion) return null;
if (promotionScope === "exclude" && request.promotion) return null;
if (ignoredKind && request.kind === ignoredKind) return null;
if (expectedKind && request.kind !== expectedKind) return null;
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
if (
expectedMessageId &&
request.displayMessageId !== expectedMessageId &&
request.messageId !== expectedMessageId
) {
return null;
}
return request;
}
@@ -166,10 +208,22 @@ function matchesPendingUnlockScope(input: {
pending: PendingChatUnlock;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
promotionScope?: PromotionScope;
}): boolean {
const { pending, expectedKind, expectedMessageId } = input;
const {
pending,
expectedKind,
expectedMessageId,
promotionScope = "any",
} = input;
if (promotionScope === "only" && !pending.promotion) return false;
if (promotionScope === "exclude" && pending.promotion) return false;
if (expectedKind && pending.kind !== expectedKind) return false;
if (expectedMessageId && pending.messageId !== expectedMessageId) {
if (
expectedMessageId &&
pending.displayMessageId !== expectedMessageId &&
pending.messageId !== expectedMessageId
) {
return false;
}
return true;