feat(chat): add promotional external entry flow
This commit is contained in:
@@ -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=""');
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user