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
@@ -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 && (