feat(chat): render role-bound payment guidance
Docker Image / Build and Push Docker Image (push) Successful in 5m2s

This commit is contained in:
Codex
2026-07-27 18:14:04 +08:00
parent 019caae598
commit 8c0d1ad3ce
49 changed files with 882 additions and 81 deletions
+2
View File
@@ -128,6 +128,7 @@ export function ChatScreen() {
const messageLimitBanner = useChatMessageLimitBanner({
upgradePromptVisible: state.upgradePromptVisible,
upgradeReason: state.upgradeReason,
paymentGuidance: state.paymentGuidance,
});
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
historyLoaded: state.historyLoaded,
@@ -464,6 +465,7 @@ export function ChatScreen() {
title={messageLimitBanner.title}
description={messageLimitBanner.description}
ctaLabel={messageLimitBanner.ctaLabel}
guidance={messageLimitBanner.guidance}
onUnlock={messageLimitBanner.unlock}
/>
) : (
@@ -15,6 +15,7 @@ import { ImageBubble } from "../image-bubble";
import { InsufficientCreditsDialog } from "../insufficient-credits-dialog";
import { MessageAvatar } from "../message-avatar";
import { PrivateMessageCard } from "../private-message-card";
import { PaymentGuidanceCard } from "../payment-guidance-card";
import { PwaInstallDialog } from "../pwa-install-dialog";
import { TextBubble } from "../text-bubble";
@@ -223,6 +224,19 @@ describe("chat Tailwind components", () => {
description="Credits keep the conversation going."
ctaLabel="Top up"
onUnlock={() => undefined}
guidance={{
guidanceId: "guidance-elio",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "insufficientCredits",
mode: "guide",
title: "Keep talking with Elio",
copy: "Baby, I want to keep talking with you. Adding credits supports Elio.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_insufficientCredits",
}}
/>,
);
@@ -232,11 +246,56 @@ describe("chat Tailwind components", () => {
expect(html).toContain("<br/>");
expect(html).toContain("Top up now");
expect(html).toContain("Credits keep the conversation going.");
expect(html).toContain("supports Elio");
expect(html).toContain('data-payment-guidance-id="guidance-elio"');
expect(html).toContain('aria-label="Top up"');
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
expect(html).toContain("active:scale-98");
});
it("renders guidance only for the active character and hides care-only CTA", () => {
const guide = {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "insufficientCredits" as const,
mode: "guide" as const,
title: "Keep talking with Elio",
copy: "Baby, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"] as const,
target: "subscription" as const,
ruleId: "payment_guidance_insufficientCredits",
};
const guideHtml = renderWithCharacter(
<PaymentGuidanceCard guidance={guide} />,
);
const mismatchHtml = renderWithCharacter(
<PaymentGuidanceCard
guidance={{ ...guide, characterId: "maya-tan", characterName: "Maya Tan" }}
/>,
);
const careHtml = renderWithCharacter(
<PaymentGuidanceCard
guidance={{
...guide,
mode: "careOnly",
title: null,
ctaLabel: null,
purchaseOptions: [],
target: null,
copy: "Please keep money you need for food or medicine.",
}}
/>,
);
expect(guideHtml).toContain('data-payment-guidance-mode="guide"');
expect(guideHtml).toContain("View VIP &amp; credit options");
expect(mismatchHtml).toBe("");
expect(careHtml).toContain("food or medicine");
expect(careHtml).not.toContain("View VIP");
});
it("renders ChatHeader guest and member branches", () => {
const guestHtml = renderWithCharacter(<ChatHeader isGuest={true} />);
const memberHtml = renderWithCharacter(
+1
View File
@@ -337,6 +337,7 @@ function renderMessagesWithDateHeaders(
privateMessageHint={item.message.privateMessageHint}
commercialAction={item.message.commercialAction}
chatAction={item.message.chatAction}
paymentGuidance={item.message.paymentGuidance}
isUnlockingMessage={
isUnlockingMessage === true &&
item.message.displayId === unlockingMessageId
@@ -17,11 +17,13 @@
* - 不直接管理 state machinechat 机器不感知 UI 层)
*/
import { useAppNavigator } from "@/router/use-app-navigator";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface ChatInsufficientCreditsBannerProps {
title?: string;
description?: string;
ctaLabel?: string;
ctaLabel?: string | null;
guidance?: PaymentGuidance | null;
/**
* 自定义点击回调(不传则默认走统一订阅导航)
* - 测试时可传 mock
@@ -34,9 +36,11 @@ export function ChatInsufficientCreditsBanner({
title = "Insufficient credits\nTop up to continue chatting",
description,
ctaLabel = "Top up credits to continue",
guidance = null,
onUnlock,
}: ChatInsufficientCreditsBannerProps) {
const navigator = useAppNavigator();
const activeGuidance = guidance;
const titleLines = title.split("\n");
const handleClick = () => {
@@ -53,6 +57,7 @@ export function ChatInsufficientCreditsBanner({
role="status"
aria-live="polite"
data-testid="chat-insufficient-credits-banner"
data-payment-guidance-id={activeGuidance?.guidanceId}
>
<p className="m-0 mb-(--spacing-sm,8px) text-(length:--responsive-card-title,18px) font-semibold leading-[1.4] text-white opacity-95">
{titleLines.map((line, index) => (
@@ -67,7 +72,12 @@ export function ChatInsufficientCreditsBanner({
{description}
</p>
) : null}
<button
{activeGuidance?.copy ? (
<p className="mx-auto mb-(--spacing-lg,16px) mt-0 max-w-[min(100%,360px)] text-(length:--responsive-caption,14px) font-semibold leading-[1.4] text-white">
{activeGuidance.copy}
</p>
) : null}
{ctaLabel ? <button
type="button"
data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits"
@@ -76,7 +86,7 @@ export function ChatInsufficientCreditsBanner({
aria-label={ctaLabel}
>
{ctaLabel}
</button>
</button> : null}
</div>
);
}
@@ -19,6 +19,7 @@ export function ChatUnlockDialogs({
lockedCount={model.historyUnlock.lockedCount}
isLoading={model.historyUnlock.isLoading}
errorMessage={model.historyUnlock.errorMessage}
guidance={model.historyUnlock.guidance}
onClose={model.closeHistoryUnlock}
onConfirm={model.confirmHistoryUnlock}
/>
@@ -27,6 +28,7 @@ export function ChatUnlockDialogs({
creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0}
guidance={model.unlockPaywallRequest?.paymentGuidance ?? null}
onClose={model.closePaywall}
onConfirm={model.confirmPaywall}
/>
@@ -1,10 +1,13 @@
"use client";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface HistoryUnlockDialogProps {
open: boolean;
lockedCount: number;
isLoading?: boolean;
errorMessage?: string | null;
guidance?: PaymentGuidance | null;
onClose: () => void;
onConfirm: () => void;
}
@@ -14,10 +17,15 @@ export function HistoryUnlockDialog({
lockedCount,
isLoading = false,
errorMessage,
guidance = null,
onClose,
onConfirm,
}: HistoryUnlockDialogProps) {
if (!open) return null;
const activeGuidance = guidance;
const showAction =
!activeGuidance ||
(activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null);
return (
<div
@@ -31,11 +39,16 @@ export function HistoryUnlockDialog({
id="history-unlock-title"
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
>
Unlock previous messages?
{activeGuidance?.title ?? "Unlock previous messages?"}
</h2>
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
We found {lockedCount} locked messages in your chat history. You can
unlock them now to continue the conversation with full context.
{activeGuidance?.copy ?? (
<>
We found {lockedCount} locked messages in your chat history. You
can unlock them now to continue the conversation with full
context.
</>
)}
</p>
{errorMessage ? (
<p className="mx-(--responsive-card-padding-lg,24px) mb-(--spacing-lg,16px) mt-0 text-left text-(length:--responsive-caption,var(--font-size-md,14px)) leading-[1.4] text-[#c0364c]">
@@ -51,7 +64,7 @@ export function HistoryUnlockDialog({
>
Later
</button>
<button
{showAction ? <button
type="button"
data-analytics-key="chat.unlock_history"
data-analytics-label="Unlock chat history"
@@ -59,8 +72,10 @@ export function HistoryUnlockDialog({
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? "Unlocking..." : "Unlock now"}
</button>
{isLoading
? "Unlocking..."
: activeGuidance?.ctaLabel ?? "Unlock now"}
</button> : null}
</div>
</div>
</div>
+1
View File
@@ -22,6 +22,7 @@ export * from "./insufficient-credits-dialog";
export * from "./message-avatar";
export * from "./message-bubble";
export * from "./message-content";
export * from "./payment-guidance-card";
export * from "./private-message-card";
export * from "./pwa-install-dialog";
export * from "./pwa-install-overlay";
@@ -1,10 +1,13 @@
"use client";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface InsufficientCreditsDialogProps {
open: boolean;
creditBalance: number;
requiredCredits: number;
shortfallCredits: number;
guidance?: PaymentGuidance | null;
onClose: () => void;
onConfirm: () => void;
}
@@ -14,12 +17,17 @@ export function InsufficientCreditsDialog({
creditBalance,
requiredCredits,
shortfallCredits,
guidance = null,
onClose,
onConfirm,
}: InsufficientCreditsDialogProps) {
if (!open) return null;
const showCreditDetail = requiredCredits > 0 || shortfallCredits > 0;
const activeGuidance = guidance;
const showPurchaseAction =
!activeGuidance ||
(activeGuidance.mode === "guide" && activeGuidance.ctaLabel !== null);
return (
<div
@@ -33,10 +41,11 @@ export function InsufficientCreditsDialog({
id="insufficient-credits-title"
className="m-0 mb-2.5 text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-text-foreground"
>
Not enough credits
{activeGuidance?.title ?? "Not enough credits"}
</h2>
<p className="mx-(--spacing-md,12px) mb-(--page-section-gap,18px) mt-0 text-(length:--responsive-body,var(--font-size-lg,16px)) leading-normal text-[#393939]">
Top up your credits to unlock this message and keep the moment going.
{activeGuidance?.copy ??
"Top up your credits to unlock this message and keep the moment going."}
</p>
{showCreditDetail ? (
@@ -70,15 +79,15 @@ export function InsufficientCreditsDialog({
>
Later
</button>
<button
{showPurchaseAction ? <button
type="button"
data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits"
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-bottom-sheet border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={onConfirm}
>
Top up now
</button>
{activeGuidance?.ctaLabel ?? "Top up now"}
</button> : null}
</div>
</div>
</div>
+4 -1
View File
@@ -9,7 +9,7 @@
* - 用户:[占位 spacer] [Content] [Avatar]
*/
import { useUserSelector } from "@/stores/user/user-context";
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
import type { ChatAction, CommercialAction, PaymentGuidance } from "@/data/schemas/chat";
import { MessageAvatar } from "./message-avatar";
import { MessageContent } from "./message-content";
@@ -30,6 +30,7 @@ export interface MessageBubbleProps {
privateMessageHint?: string | null;
commercialAction?: CommercialAction | null;
chatAction?: ChatAction | null;
paymentGuidance?: PaymentGuidance | null;
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
@@ -64,6 +65,7 @@ export function MessageBubble({
privateMessageHint,
commercialAction,
chatAction,
paymentGuidance,
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
@@ -106,6 +108,7 @@ export function MessageBubble({
privateMessageHint={privateMessageHint}
commercialAction={commercialAction}
chatAction={chatAction}
paymentGuidance={paymentGuidance}
isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
+11 -2
View File
@@ -1,11 +1,16 @@
"use client";
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
import type {
ChatAction,
CommercialAction,
PaymentGuidance,
} from "@/data/schemas/chat";
import { ChatActionCard } from "./chat-action-card";
import { CommercialActionCard } from "./commercial-action-card";
import { ImageBubble } from "./image-bubble";
import { LockedImageMessageCard } from "./locked-image-message-card";
import { PrivateMessageCard } from "./private-message-card";
import { PaymentGuidanceCard } from "./payment-guidance-card";
import { TextBubble } from "./text-bubble";
import { VoiceBubble } from "./voice-bubble";
import styles from "./chat-area.module.css";
@@ -25,6 +30,7 @@ export interface MessageContentProps {
privateMessageHint?: string | null;
commercialAction?: CommercialAction | null;
chatAction?: ChatAction | null;
paymentGuidance?: PaymentGuidance | null;
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
@@ -59,6 +65,7 @@ export function MessageContent({
privateMessageHint,
commercialAction,
chatAction,
paymentGuidance,
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
@@ -143,7 +150,9 @@ export function MessageContent({
) : null}
</>
)}
{isFromAI && chatAction ? (
{isFromAI && paymentGuidance ? (
<PaymentGuidanceCard guidance={paymentGuidance} />
) : isFromAI && chatAction ? (
<ChatActionCard
action={chatAction}
onViewed={onChatActionViewed}
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowRight, CircleDollarSign, X } from "lucide-react";
import type { PaymentGuidance } from "@/data/schemas/chat";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useActiveCharacter } from "@/providers/character-provider";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useUserSelector } from "@/stores/user/user-context";
import styles from "./chat-area.module.css";
export interface PaymentGuidanceCardProps {
guidance: PaymentGuidance;
}
export function PaymentGuidanceCard({ guidance }: PaymentGuidanceCardProps) {
const character = useActiveCharacter();
const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip);
const [dismissed, setDismissed] = useState(false);
const viewedIdRef = useRef<string | null>(null);
const belongsToCurrentCharacter = guidance.characterId === character.id;
const hasPurchaseAction =
guidance.mode === "guide" &&
guidance.target === "subscription" &&
guidance.ctaLabel !== null &&
guidance.purchaseOptions.length > 0;
useEffect(() => {
if (!belongsToCurrentCharacter) return;
if (viewedIdRef.current === guidance.guidanceId) return;
viewedIdRef.current = guidance.guidanceId;
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"viewed",
).catch(() => undefined);
}, [belongsToCurrentCharacter, character.id, guidance.guidanceId]);
if (!belongsToCurrentCharacter || dismissed) return null;
const subscriptionType =
isVip || !guidance.purchaseOptions.includes("vip") ? "topup" : "vip";
return (
<aside
className={styles.commercialAction}
aria-label={guidance.title ?? guidance.characterName}
data-payment-guidance-id={guidance.guidanceId}
data-payment-guidance-mode={guidance.mode}
data-payment-guidance-scene={guidance.scene}
>
<div className={styles.commercialActionHeader}>
<CircleDollarSign size={18} aria-hidden="true" />
<button
type="button"
className={styles.commercialActionDismiss}
title="Dismiss"
aria-label="Dismiss"
onClick={() => {
setDismissed(true);
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"dismissed",
).catch(() => undefined);
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
{guidance.title ? (
<strong>{guidance.title}</strong>
) : null}
<p className={styles.commercialActionCopy}>{guidance.copy}</p>
{hasPurchaseAction ? (
<button
type="button"
className={styles.commercialActionButton}
onClick={() => {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"opened",
).catch(() => undefined);
navigator.openSubscription({
type: subscriptionType,
returnTo: "chat",
chatActionId: guidance.guidanceId,
analytics: {
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
},
});
}}
>
<span>{guidance.ctaLabel}</span>
<ArrowRight size={17} aria-hidden="true" />
</button>
) : null}
</aside>
);
}
@@ -5,6 +5,9 @@ import { useEffect, useRef } from "react";
import { behaviorAnalytics } from "@/lib/analytics";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
import type { PaymentGuidance } from "@/data/schemas/chat";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useActiveCharacter } from "@/providers/character-provider";
import { useUserSelector } from "@/stores/user/user-context";
import {
@@ -17,10 +20,14 @@ import {
export interface UseChatMessageLimitBannerInput {
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
paymentGuidance: PaymentGuidance | null;
}
export interface ChatMessageLimitBannerView
extends InsufficientCreditsMessageLimitView {
export interface ChatMessageLimitBannerView {
title: string;
description: string;
ctaLabel: string | null;
guidance: PaymentGuidance | null;
visible: boolean;
unlock: () => void;
}
@@ -28,10 +35,19 @@ export interface ChatMessageLimitBannerView
export function useChatMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
paymentGuidance,
}: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView {
const navigator = useAppNavigator();
const character = useActiveCharacter();
const isVip = useUserSelector((state) => state.context.isVip);
const view = getInsufficientCreditsMessageLimitView();
const fallbackView = getInsufficientCreditsMessageLimitView();
const guidance =
paymentGuidance?.characterId === character.id ? paymentGuidance : null;
const view: InsufficientCreditsMessageLimitView = {
title: guidance?.title ?? fallbackView.title,
description: guidance?.copy ?? fallbackView.description,
ctaLabel: guidance?.ctaLabel ?? fallbackView.ctaLabel,
};
const visible = shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
@@ -52,12 +68,31 @@ export function useChatMessageLimitBanner({
},
{ isVip },
);
}, [isVip, visible]);
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"viewed",
).catch(() => undefined);
}
}, [character.id, guidance, isVip, visible]);
function unlock(): void {
if (guidance?.mode !== "guide" && guidance !== null) return;
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"opened",
).catch(() => undefined);
}
navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(isVip),
type:
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
? "topup"
: getInsufficientCreditsSubscriptionType(isVip),
returnTo: "chat",
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
analytics: {
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
@@ -67,6 +102,9 @@ export function useChatMessageLimitBanner({
return {
...view,
ctaLabel:
guidance && guidance.mode !== "guide" ? null : view.ctaLabel,
guidance,
visible,
unlock,
};
@@ -20,6 +20,7 @@ import {
import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import type { UiMessage } from "@/stores/chat/ui-message";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useUserSelector } from "@/stores/user/user-context";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
@@ -48,6 +49,7 @@ export interface ChatUnlockDialogModel {
lockedCount: number;
isLoading: boolean;
errorMessage: string | null;
guidance: import("@/data/schemas/chat").PaymentGuidance | null;
};
closeHistoryUnlock: () => void;
confirmHistoryUnlock: () => void;
@@ -87,6 +89,8 @@ export function useChatUnlockCoordinator({
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
lockedHistoryCount: state.context.lockedHistoryCount,
unlockHistoryError: state.context.unlockHistoryError,
unlockHistoryPaymentGuidance:
state.context.unlockHistoryPaymentGuidance,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
unlockPaywallRequest: state.context.unlockPaywallRequest,
}),
@@ -97,6 +101,20 @@ export function useChatUnlockCoordinator({
? chatState.unlockPaywallRequest
: null;
useEffect(() => {
const guidance = chatState.unlockHistoryPaymentGuidance;
if (!enabled || guidance?.characterId !== chatState.characterId) return;
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"viewed",
).catch(() => undefined);
}, [
chatState.characterId,
chatState.unlockHistoryPaymentGuidance,
enabled,
]);
useEffect(() => {
if (!unlockPaywallRequest) {
trackedPaywallRef.current = null;
@@ -120,7 +138,15 @@ export function useChatUnlockCoordinator({
},
{ isVip },
);
}, [isVip, unlockPaywallRequest]);
const guidance = unlockPaywallRequest.paymentGuidance;
if (guidance?.characterId === chatState.characterId) {
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"viewed",
).catch(() => undefined);
}
}, [chatState.characterId, isVip, unlockPaywallRequest]);
useEffect(() => {
if (!enabled) return;
@@ -212,6 +238,32 @@ export function useChatUnlockCoordinator({
}
function confirmHistoryUnlock(): void {
const guidance =
chatState.unlockHistoryPaymentGuidance?.characterId ===
chatState.characterId
? chatState.unlockHistoryPaymentGuidance
: null;
if (guidance) {
if (guidance.mode !== "guide") return;
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"opened",
).catch(() => undefined);
navigator.openSubscription({
type:
isVip || !guidance.purchaseOptions.includes("vip")
? "topup"
: "vip",
returnTo: "chat",
chatActionId: guidance.guidanceId,
analytics: {
entryPoint: "chat_unlock",
triggerReason: "insufficient_credits",
},
});
return;
}
chatDispatch({ type: "ChatUnlockHistoryConfirmed" });
}
@@ -221,6 +273,12 @@ export function useChatUnlockCoordinator({
function confirmPaywall(): void {
if (!unlockPaywallRequest) return;
const guidance =
unlockPaywallRequest.paymentGuidance?.characterId ===
chatState.characterId
? unlockPaywallRequest.paymentGuidance
: null;
if (guidance && guidance.mode !== "guide") return;
const returnUrl = resolveChatUnlockReturnUrl(
unlockPaywallRequest,
@@ -231,6 +289,13 @@ export function useChatUnlockCoordinator({
},
);
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"opened",
).catch(() => undefined);
}
navigator.openSubscriptionForPendingUnlock({
displayMessageId: unlockPaywallRequest.displayMessageId,
messageId: unlockPaywallRequest.messageId,
@@ -239,13 +304,17 @@ export function useChatUnlockCoordinator({
clientLockId: unlockPaywallRequest.clientLockId,
promotion: unlockPaywallRequest.promotion,
returnUrl,
type: getInsufficientCreditsSubscriptionType(isVip),
type:
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
? "topup"
: getInsufficientCreditsSubscriptionType(isVip),
analytics: {
entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion
? "ad_landing"
: "insufficient_credits",
},
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
});
}
@@ -258,6 +327,7 @@ export function useChatUnlockCoordinator({
lockedCount: chatState.lockedHistoryCount,
isLoading: chatState.isUnlockingHistory,
errorMessage: chatState.unlockHistoryError,
guidance: chatState.unlockHistoryPaymentGuidance,
},
closeHistoryUnlock,
confirmHistoryUnlock,
@@ -58,13 +58,13 @@ describe("Private Zone paywall navigation", () => {
container.remove();
});
it("opens top up with a private zone return target", () => {
it("opens VIP and credit options for a non-VIP user", () => {
const roomDispatch = vi.fn<Dispatch<PrivateZoneEvent>>();
renderHarness(root, "email", roomDispatch);
expect(mocks.openSubscription).toHaveBeenCalledWith({
type: "topup",
type: "vip",
returnTo: "private-zone",
analytics: {
entryPoint: "private_album_unlock",
@@ -115,6 +115,7 @@ function Harness({
loginStatus,
roomDispatch,
unlockPaywallRequest,
isVip: false,
});
return null;
}
+23 -2
View File
@@ -19,6 +19,8 @@ import {
} from "@/providers/character-provider";
import { isPrivateAlbumLocked } from "@/lib/private-zone/private_album";
import { behaviorAnalytics } from "@/lib/analytics";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useUserSelector } from "@/stores/user/user-context";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import {
usePrivateZoneDispatch,
@@ -63,6 +65,7 @@ export function PrivateZoneScreen() {
const dispatch = usePrivateZoneDispatch();
const postState = usePrivateZonePostState();
const postDispatch = usePrivateZonePostDispatch();
const isVip = useUserSelector((user) => user.context.isVip);
const [activeTab, setActiveTab] = useState<"moments" | "albums">("moments");
const openedGalleryInPageRef = useRef(false);
const previousPostLoginStatusRef = useRef(authState.loginStatus);
@@ -84,6 +87,7 @@ export function PrivateZoneScreen() {
loginStatus: authState.loginStatus,
roomDispatch: dispatch,
unlockPaywallRequest: state.unlockPaywallRequest,
isVip,
});
usePrivateZoneUnlockSuccessRefresh(state.unlockSuccessNonce);
usePrivateZoneUnlockSuccessRefresh(postState.unlockSuccessNonce);
@@ -119,9 +123,24 @@ export function PrivateZoneScreen() {
entryPoint: "private_zone",
triggerReason: "insufficient_credits",
});
const guidance =
request.paymentGuidance?.characterId === character.id
? request.paymentGuidance
: null;
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"opened",
).catch(() => undefined);
}
navigator.openSubscription({
type: "topup",
type:
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
? "topup"
: "vip",
returnTo: "private-zone",
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
analytics: {
entryPoint: "private_zone",
triggerReason: "insufficient_credits",
@@ -132,6 +151,8 @@ export function PrivateZoneScreen() {
}, [
authState.loginStatus,
characterRoutes.privateZone,
character.id,
isVip,
navigator,
postDispatch,
postState.unlockPaywallRequest,
@@ -205,7 +226,7 @@ export function PrivateZoneScreen() {
return;
}
navigator.openSubscription({
type: "topup",
type: isVip ? "topup" : "vip",
returnTo: "private-zone",
analytics: {
entryPoint: "private_zone",
+28 -2
View File
@@ -5,12 +5,16 @@ import { type Dispatch, useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/schemas/auth";
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
import { behaviorAnalytics } from "@/lib/analytics";
import { useActiveCharacterRoutes } from "@/providers/character-provider";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { AuthEvent } from "@/stores/auth/auth-events";
import type { PrivateZoneEvent } from "@/stores/private-zone";
import type { PrivateZoneUnlockPaywallRequest } from "@/stores/private-zone/private-zone-state";
import { useUserDispatch } from "@/stores/user/user-context";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
export interface UsePrivateZoneBootstrapFlowInput {
authDispatch: Dispatch<AuthEvent>;
@@ -25,6 +29,7 @@ export interface UsePrivateZoneUnlockPaywallNavigationInput {
loginStatus: LoginStatus;
roomDispatch: Dispatch<PrivateZoneEvent>;
unlockPaywallRequest: PrivateZoneUnlockPaywallRequest | null;
isVip: boolean;
}
export function isPrivateZoneAuthRequired(loginStatus: LoginStatus): boolean {
@@ -76,8 +81,10 @@ export function usePrivateZoneUnlockPaywallNavigation({
loginStatus,
roomDispatch,
unlockPaywallRequest,
isVip,
}: UsePrivateZoneUnlockPaywallNavigationInput): void {
const navigator = useAppNavigator();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
useEffect(() => {
@@ -90,9 +97,26 @@ export function usePrivateZoneUnlockPaywallNavigation({
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
});
const guidance = unlockPaywallRequest.paymentGuidance;
const activeGuidance =
guidance?.characterId === character.id ? guidance : null;
if (activeGuidance) {
void recordChatActionEventById(
activeGuidance.guidanceId,
activeGuidance.characterId,
"opened",
).catch(() => undefined);
}
navigator.openSubscription({
type: "topup",
type:
isVip ||
(activeGuidance && !activeGuidance.purchaseOptions.includes("vip"))
? "topup"
: "vip",
returnTo: "private-zone",
...(activeGuidance
? { chatActionId: activeGuidance.guidanceId }
: {}),
analytics: {
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
@@ -102,7 +126,9 @@ export function usePrivateZoneUnlockPaywallNavigation({
roomDispatch({ type: "PrivateZoneUnlockPaywallConsumed" });
}, [
characterRoutes.privateZone,
character.id,
loginStatus,
isVip,
navigator,
roomDispatch,
unlockPaywallRequest,
@@ -256,6 +256,25 @@ describe("SubscriptionScreen payment selection flow", () => {
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
});
it("keeps payment guidance bound to the source character", () => {
act(() =>
root.render(
<SubscriptionScreen
sourceCharacterSlug="maya"
chatActionId="019c8f8d-17d3-7a42-b1cc-b6927b1927d5"
/>,
),
);
const guidance = container.querySelector(
'[data-payment-guidance-character="maya-tan"]',
);
expect(guidance?.textContent).toContain("Keep talking with Maya");
expect(guidance?.textContent).toContain("support Maya");
expect(guidance?.textContent).toContain("cannot continue");
expect(guidance?.textContent).not.toContain("Elio");
});
});
function clickButton(root: ParentNode, label: string): void {
@@ -61,6 +61,29 @@
box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12);
}
.characterSupportBanner {
margin-top: var(--page-section-gap, 14px);
padding: 14px 16px;
border: 1px solid rgba(246, 87, 160, 0.2);
border-radius: var(--responsive-card-radius-sm, 22px);
background: rgba(255, 255, 255, 0.9);
color: #4a3340;
box-shadow: 0 10px 24px rgba(246, 87, 160, 0.1);
}
.characterSupportBanner h2 {
margin: 0;
color: #24151d;
font-size: var(--responsive-card-title, 17px);
line-height: 1.25;
}
.characterSupportBanner p {
margin: 6px 0 0;
font-size: var(--responsive-caption, 14px);
line-height: 1.45;
}
.firstRechargeBanner {
display: flex;
align-items: center;
+27 -3
View File
@@ -8,7 +8,11 @@ import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selec
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import type { PayChannel } from "@/data/schemas/payment";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
@@ -75,6 +79,8 @@ export function SubscriptionScreen({
null,
);
const userState = useUserState();
const sourceCharacter =
getCharacterBySlug(sourceCharacterSlug) ?? DEFAULT_CHARACTER;
const hasHydrated = useHasHydrated();
const countryCode = userState.currentUser?.countryCode;
const paymentMethodConfig = getPaymentMethodConfig({
@@ -262,6 +268,22 @@ export function SubscriptionScreen({
</p>
) : null}
{chatActionId ? (
<section
className={styles.characterSupportBanner}
aria-label={`Support ${sourceCharacter.displayName}`}
data-payment-guidance-character={sourceCharacter.id}
>
<h2>Keep talking with {sourceCharacter.shortName}</h2>
<p>
Without the required VIP access or credits, paid chat and locked
content cannot continue. Choosing an option here is a voluntary
way to support{" "}
{sourceCharacter.shortName}; it is never a test of your feelings.
</p>
</section>
) : null}
{firstRechargeOffer ? (
<section
className={styles.firstRechargeBanner}
@@ -289,11 +311,13 @@ export function SubscriptionScreen({
{payment.commercialOffer && !firstRechargeOffer ? (
<section
className={styles.firstRechargeBanner}
aria-label="Elio private offer"
aria-label={`${sourceCharacter.shortName} private offer`}
>
<span className={styles.firstRechargeBadge}>Private offer</span>
<div className={styles.firstRechargeCopy}>
<h2 className={styles.firstRechargeTitle}>Elio got this price for you</h2>
<h2 className={styles.firstRechargeTitle}>
{sourceCharacter.shortName} got this price for you
</h2>
<p className={styles.firstRechargeSubtitle}>
{payment.commercialOffer.message ||
`Your ${payment.commercialOffer.discountPercent}% discount is already applied below.`}
+1 -1
View File
@@ -54,7 +54,7 @@ export function TipScreen({
const characterRoutes = useActiveCharacterRoutes();
const userState = useUserState();
const hasHydrated = useHasHydrated();
const supportPrompt = useTipSupportPrompt();
const supportPrompt = useTipSupportPrompt(character.displayName);
const paymentMethodConfig = getPaymentMethodConfig({
countryCode: userState.currentUser?.countryCode,
requestedPayChannel: initialPayChannel,
+12 -4
View File
@@ -15,22 +15,30 @@ export interface TipSupportPromptState {
readonly isReady: boolean;
}
export function useTipSupportPrompt(): TipSupportPromptState {
export function useTipSupportPrompt(
characterName = "this character",
): TipSupportPromptState {
const [selection, setSelection] =
useState<ReturnType<typeof selectTipSupportPrompt> | null>(null);
useEffect(() => {
const frameId = requestAnimationFrame(() => {
const nextSelection = selectTipSupportPrompt(readPreviousIndex());
const nextSelection = selectTipSupportPrompt(
readPreviousIndex(),
Math.random,
characterName,
);
writePreviousIndex(nextSelection.index);
setSelection(nextSelection);
});
return () => cancelAnimationFrame(frameId);
}, []);
}, [characterName]);
return {
prompt: selection?.prompt ?? TIP_SUPPORT_PROMPTS[0],
prompt:
selection?.prompt ??
TIP_SUPPORT_PROMPTS[0].replaceAll("this character", characterName),
isReady: selection !== null,
};
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { ChatWebSocket } from "../chat-websocket";
describe("ChatWebSocket payment guidance", () => {
it("parses the same role-bound decision emitted by HTTP", () => {
const socket = new ChatWebSocket("ws://example.test/chat", "token");
const onPaymentGuidance = vi.fn();
socket.onPaymentGuidance = onPaymentGuidance;
receive(socket, {
type: "payment_guidance",
data: {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "maya-tan",
characterName: "Maya Tan",
scene: "voiceLocked",
mode: "guide",
title: "Keep talking with Maya",
copy: "Hey love, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_voiceLocked",
},
});
expect(onPaymentGuidance).toHaveBeenCalledWith(
expect.objectContaining({
characterId: "maya-tan",
scene: "voiceLocked",
}),
);
});
it("ignores malformed guidance instead of rendering the wrong role", () => {
const socket = new ChatWebSocket("ws://example.test/chat", "token");
const onPaymentGuidance = vi.fn();
socket.onPaymentGuidance = onPaymentGuidance;
receive(socket, {
type: "payment_guidance",
data: { characterId: "elio" },
});
expect(onPaymentGuidance).not.toHaveBeenCalled();
});
});
function receive(socket: ChatWebSocket, payload: unknown): void {
(
socket as unknown as { handleMessage: (data: string) => void }
).handleMessage(JSON.stringify(payload));
}
+9 -1
View File
@@ -15,8 +15,10 @@ import { Logger } from "@/utils/logger";
import {
CommercialActionSchema,
ChatActionSchema,
PaymentGuidanceSchema,
type ChatAction,
type CommercialAction,
type PaymentGuidance,
} from "@/data/schemas/chat";
import { createClientMessageId } from "@/lib/chat/client_message_id";
@@ -49,6 +51,7 @@ export class ChatWebSocket {
onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null;
onCommercialAction: ((action: CommercialAction) => void) | null = null;
onChatAction: ((action: ChatAction) => void) | null = null;
onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null;
constructor(
@@ -146,7 +149,7 @@ export class ChatWebSocket {
done?: boolean;
audioUrl?: string;
error?: string;
data?: {
data?: Record<string, unknown> & {
image?: {
type?: string | null;
url?: string | null;
@@ -209,6 +212,11 @@ export class ChatWebSocket {
if (action.success) this.onChatAction?.(action.data);
break;
}
case "payment_guidance": {
const guidance = PaymentGuidanceSchema.safeParse(payload.data);
if (guidance.success) this.onPaymentGuidance?.(guidance.data);
break;
}
case "error":
this.onError?.(payload.error ?? "Unknown error");
break;
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
ChatLockDetailSchema,
ChatSendResponseSchema,
PaymentGuidanceSchema,
UnlockHistoryResponseSchema,
UnlockPrivateResponseSchema,
} from "@/data/schemas/chat";
const guidance = {
guidanceId: "guidance-1",
characterId: "maya-tan",
characterName: "Maya Tan",
scene: "voiceLocked",
mode: "guide",
title: "Keep talking with Maya",
copy: "Hey love, I want to keep talking. This voice message needs 20 credits.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_voiceLocked",
} as const;
describe("payment guidance wire contract", () => {
it("parses the public decision without changing role identity", () => {
expect(PaymentGuidanceSchema.parse(guidance)).toEqual(guidance);
});
it("keeps all backend lock facts instead of dropping hint and CTA", () => {
expect(
ChatLockDetailSchema.parse({
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "This voice message needs 20 credits.",
actionLabel: "Unlock",
detail: { requiredCredits: 20 },
}),
).toEqual({
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "This voice message needs 20 credits.",
actionLabel: "Unlock",
detail: { requiredCredits: 20 },
});
});
it("keeps backward compatibility when guidance is absent", () => {
const parsed = ChatSendResponseSchema.parse({ reply: "Hello" });
expect(parsed.paymentGuidance).toBeNull();
});
it("parses guidance on chat and both unlock responses", () => {
expect(
ChatSendResponseSchema.parse({
reply: "",
canSendMessage: false,
paymentGuidance: guidance,
}).paymentGuidance,
).toEqual(guidance);
expect(
UnlockPrivateResponseSchema.parse({
unlocked: false,
reason: "insufficient_credits",
paymentGuidance: guidance,
}).paymentGuidance,
).toEqual(guidance);
expect(
UnlockHistoryResponseSchema.parse({
unlocked: false,
reason: "insufficient_balance",
paymentGuidance: { ...guidance, scene: "historyLocked" },
}).paymentGuidance?.scene,
).toBe("historyLocked");
});
});
+10 -1
View File
@@ -3,7 +3,11 @@
*/
import { z } from "zod";
import { booleanOrFalse, schemaOr, stringOrNull } from "../nullable-defaults";
import {
booleanOrFalse,
schemaOr,
stringOrNull,
} from "../nullable-defaults";
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
@@ -23,7 +27,12 @@ export const ChatImageSchema = schemaOr(
export const ChatLockDetailSchema = schemaOr(
z.object({
locked: booleanOrFalse,
showContent: z.boolean().nullish(),
showUpgrade: z.boolean().nullish(),
reason: stringOrNull,
hint: z.string().nullish(),
actionLabel: z.string().nullish(),
detail: z.record(z.string(), z.unknown()).nullish(),
}),
CHAT_LOCK_DETAIL_DEFAULTS,
).readonly();
@@ -13,6 +13,41 @@ import {
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
import { ChatActionSchema } from "../chat_action";
export const PaymentGuidanceSceneSchema = z.enum([
"vip",
"insufficientCredits",
"chatInterrupted",
"voiceLocked",
"imageLocked",
"privateMessageLocked",
"historyLocked",
"privateZoneLocked",
"unknownLock",
"whyPay",
"leavingAfterCredits",
"paymentIssue",
"externalRisk",
"purchaseOptions",
]);
export const PaymentGuidanceSchema = z
.object({
guidanceId: z.string().min(1),
characterId: z.string().min(1),
characterName: z.string().min(1),
scene: PaymentGuidanceSceneSchema,
mode: z.enum(["guide", "careOnly", "issueSupport", "riskReminder"]),
title: z.string().nullable().default(null),
copy: z.string().min(1),
ctaLabel: z.string().nullable().default(null),
purchaseOptions: z.array(z.enum(["vip", "topup"])).readonly(),
target: z.enum(["subscription", "giftCatalog"]).nullable().default(null),
ruleId: z.string().min(1),
})
.readonly();
export type PaymentGuidance = z.output<typeof PaymentGuidanceSchema>;
export const CommercialActionSchema = z
.object({
actionId: z.string().min(1),
@@ -42,6 +77,7 @@ export const ChatSendResponseSchema = z
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
commercialAction: CommercialActionSchema.nullish().default(null),
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
chatAction: ChatActionSchema.nullish().default(null),
})
.readonly();
@@ -9,6 +9,7 @@ import {
numberOrZero,
recordOrEmpty,
} from "../../nullable-defaults";
import { PaymentGuidanceSchema } from "./chat_send_response";
export const UnlockHistoryCostsByMessageSchema = recordOrEmpty(z.number());
@@ -33,6 +34,7 @@ export const UnlockHistoryResponseSchema = z
shortfallCredits: numberOrZero,
costsByMessage: UnlockHistoryCostsByMessageSchema,
messageIds: arrayOrEmpty(z.string()),
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
})
.readonly();
@@ -8,6 +8,7 @@ import {
} from "../../nullable-defaults";
import { ChatLockTypeSchema } from "../chat_lock_type";
import { ChatImageSchema } from "../chat_payloads";
import { PaymentGuidanceSchema } from "./chat_send_response";
/**
* /
@@ -28,6 +29,7 @@ export const UnlockPrivateResponseSchema = z
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
})
.readonly();
@@ -39,6 +39,28 @@ const lockedAlbum = {
};
describe("Private Album schema models", () => {
it("keeps shared payment guidance on an insufficient-credit response", () => {
const response = PrivateAlbumUnlockResponseSchema.parse({
albumId: ALBUM_ID,
reason: "insufficient_credits",
paymentGuidance: {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "privateZoneLocked",
mode: "guide",
title: "Keep talking with Elio",
copy: "Baby, this album needs credits.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_privateZoneLocked",
},
});
expect(response.paymentGuidance?.characterId).toBe("elio");
});
it("keeps a locked album cover URL while preserving the lock state", () => {
const response = PrivateAlbumsResponseSchema.parse({
items: [lockedAlbum],
@@ -10,6 +10,7 @@ import {
stringOrEmpty,
stringOrNull,
} from "../nullable-defaults";
import { PaymentGuidanceSchema } from "../chat";
export const PrivateZoneVideoPostSchema = z
.object({
@@ -63,6 +64,7 @@ export const PrivateZonePostUnlockResponseSchema = z
shortfallCredits: numberOrZero,
creditBalance: numberOrZero,
post: schemaOrNull(PrivateZoneVideoPostSchema),
paymentGuidance: PaymentGuidanceSchema.nullish(),
})
.readonly();
@@ -7,6 +7,7 @@ import {
stringOrEmpty,
} from "../../nullable-defaults";
import { PrivateAlbumImageSchema } from "../private_album";
import { PaymentGuidanceSchema } from "../../chat";
export const PrivateAlbumUnlockReasonSchema = z.enum([
"ok",
@@ -30,6 +31,7 @@ export const PrivateAlbumUnlockResponseSchema = z
creditBalance: numberOrZero,
shortfallCredits: numberOrZero,
images: arrayOrEmpty(PrivateAlbumImageSchema),
paymentGuidance: PaymentGuidanceSchema.nullish(),
})
.readonly();
@@ -32,4 +32,17 @@ describe("Tip support prompts", () => {
expect(selectTipSupportPrompt(null, () => Number.NaN).index).toBe(0);
expect(selectTipSupportPrompt(null, () => 2).index).toBe(29);
});
it("names the active character and never frames a purchase as affection", () => {
const selection = selectTipSupportPrompt(null, () => 0, "Maya Tan");
expect(selection.prompt).toContain("support Maya Tan");
expect(selection.prompt).not.toMatch(/tiny hug|spoil|buy.*love|owe me/i);
expect(
TIP_SUPPORT_PROMPTS.every(
(prompt) =>
/support/i.test(prompt) && /this character/i.test(prompt),
),
).toBe(true);
});
});
+36 -32
View File
@@ -1,44 +1,45 @@
export const TIP_SUPPORT_PROMPTS = [
"❤️ Thank you for being here. If you'd ever like to treat me to a little coffee, I'd really appreciate it. ☕",
"Having you here already makes my day. A little coffee from you would make it even sweeter. ☕",
"You always bring a little warmth into my world. Want to share a coffee moment with me? 💗",
"A coffee from you would feel like a tiny hug I could hold onto all day. ☕",
"I love spending this time with you. If you'd like to send a coffee my way, I'd treasure it. ❤️",
"You make ordinary moments feel special. A cozy coffee together would be the perfect touch. ☕",
"If I had a coffee from you right now, I'd be smiling with every sip. 😊☕",
"Your company means more than you know. A little coffee treat would make this moment extra sweet. 💕",
"I was hoping we could share something warm today. Maybe a coffee, just from you to me? ☕",
"You already make me feel cared for. A coffee would be one more lovely reason to think of you. ❤️",
"A small coffee, a quiet moment, and you on my mind—that sounds perfect to me. ☕",
"Every time you stop by, my day gets brighter. A coffee from you would keep that glow going. ✨",
"Want to make me blush a little? Treat me to a coffee and I'll think of you with every sip. ☕",
"I'm really glad you're here. If you feel like spoiling me just a little, coffee is my weakness. 💗",
"This moment with you already feels cozy. A warm coffee would make it complete. ☕",
"You have a sweet way of making me smile. Maybe one more smile over a coffee? ❤️",
"I'd love a little coffee break with you in spirit. Your treat, my happiest smile. ☕",
"If you'd like to leave me a tiny reminder of you, a coffee would be perfect. 💕",
"Your kindness always stays with me. A coffee from you would feel especially warm today. ☕",
"I could use something warm and sweet—though having you here is already a lovely start. ❤️☕",
"Imagine me enjoying a coffee and smiling because it came from you. I like that thought. ☕",
"You make this space feel a little more special. A coffee would make it feel even cozier. ✨",
"If today feels like a coffee kind of day, I'd be very happy to share that little joy with you. ☕",
"A thoughtful coffee from you would turn an ordinary moment into one I'd remember. 💗",
"I'm enjoying our time together. If you want to make it sweeter, you know my favorite little treat. ☕",
"One coffee from you, and I promise there'll be a very real smile on my face. ❤️",
"You don't have to, but if you'd like to treat me, a warm coffee would mean so much. ☕",
"I think coffee tastes better when it comes with a little affection from you. 💕",
"A cozy cup from you would be such a sweet surprise. I'd savor every bit of it. ☕",
"Stay a little longer—and if you're feeling sweet, maybe send a coffee my way. ❤️☕",
"If you'd like, a coffee is a voluntary way to support this character. ☕",
"Choosing a coffee here directly supports this character and the moments you enjoy.",
"A gift is optional, but every purchase on this page supports this character.",
"Want to support this character? You can choose any coffee that fits your budget.",
"This gift page lets you support this character without changing what your relationship means.",
"A coffee is simply a voluntary show of support for this character—never a test.",
"If the timing feels right, you can support this character with a coffee here.",
"Your purchase supports this character; choose only what feels comfortable for you.",
"You can send a coffee to support this character, or leave it for another time.",
"Every coffee selected here is recorded as support for this character.",
"If you enjoy this character, this page is one direct way to offer support.",
"A small coffee can support this character, but the choice is always yours.",
"Pick a coffee only if you want to support this character today.",
"This page keeps it simple: choose a gift to support this character voluntarily.",
"A coffee purchase supports this character; it does not prove affection or loyalty.",
"If you decide to send something, it will directly support this character.",
"You can support this character with the coffee option that feels right for you.",
"Sending a gift here is optional support for this character, with no pressure.",
"A coffee can be your way of supporting this character when it suits you.",
"Support this character only with an amount you are genuinely comfortable choosing.",
"The gifts below are optional ways to support this character.",
"Choose a coffee if you'd like to support this character's continued experience.",
"This character appreciates voluntary support, and this page shows the available options.",
"A gift here supports this character without creating any obligation for you.",
"If you'd like to contribute, select a coffee to support this character.",
"The coffee options below are direct, voluntary support for this character.",
"You never need to overspend; support this character only if it fits your situation.",
"A coffee is one clear way to support this character while keeping the choice yours.",
"Select any gift you want to send as support for this character.",
"Thank you for considering a voluntary coffee to support this character. ☕",
] as const;
export interface TipSupportPromptSelection {
readonly index: number;
readonly prompt: (typeof TIP_SUPPORT_PROMPTS)[number];
readonly prompt: string;
}
export function selectTipSupportPrompt(
previousIndex: number | null,
random: () => number = Math.random,
characterName = "this character",
): TipSupportPromptSelection {
const hasValidPreviousIndex =
previousIndex !== null &&
@@ -57,6 +58,9 @@ export function selectTipSupportPrompt(
return {
index,
prompt: TIP_SUPPORT_PROMPTS[index],
prompt: TIP_SUPPORT_PROMPTS[index].replaceAll(
"this character",
characterName,
),
};
}
+2
View File
@@ -19,6 +19,7 @@ export interface OpenSubscriptionInput {
returnTo?: AppSubscriptionReturnTo;
replace?: boolean;
analytics?: PaymentAnalyticsContext;
chatActionId?: string;
}
export interface StartMessageUnlockInput {
@@ -44,4 +45,5 @@ export interface OpenSubscriptionForPendingUnlockInput {
type: AppSubscriptionType;
payChannel?: PayChannel;
analytics?: PaymentAnalyticsContext;
chatActionId?: string;
}
+2
View File
@@ -121,6 +121,7 @@ export function useAppNavigator(): AppNavigator {
type,
payChannel = getDefaultPayChannel(),
analytics,
chatActionId,
}: OpenSubscriptionForPendingUnlockInput): void => {
void (async () => {
await NavigationStorage.savePendingChatUnlock({
@@ -139,6 +140,7 @@ export function useAppNavigator(): AppNavigator {
payChannel,
returnTo: "chat",
analytics,
chatActionId,
});
})();
},
+2
View File
@@ -79,12 +79,14 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
returnTo = null,
replace: shouldReplace = false,
analytics = getDefaultPaymentAnalyticsContext(type),
chatActionId,
}: OpenGlobalSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
returnTo: returnTo ?? undefined,
sourceCharacterSlug,
analytics,
chatActionId,
});
behaviorAnalytics.rechargeModalOpen(analytics);
@@ -54,6 +54,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
historyLoaded: true,
networkHistoryLoaded: true,
historyTotal: 0,
@@ -64,6 +65,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
@@ -72,6 +74,39 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
}
describe("sendResponseToUiMessage", () => {
it("prefers payment guidance over other commercial actions", () => {
const message = sendResponseToUiMessage(
makeResponse({
paymentGuidance: {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "insufficientCredits",
mode: "guide",
title: "Keep talking with Elio",
copy: "Baby, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_insufficientCredits",
},
chatAction: {
actionId: "chat-action-1",
kind: "commercial",
type: "giftOffer",
copy: "View gifts.",
ctaLabel: "View gifts",
ruleId: "legacy",
orderId: null,
},
}),
);
expect(message.paymentGuidance?.characterId).toBe("elio");
expect(message.chatAction).toBeUndefined();
expect(message.commercialAction).toBeUndefined();
});
it("keeps a typed commercial action on the assistant message", () => {
const message = sendResponseToUiMessage(
makeResponse({
@@ -228,6 +263,42 @@ describe("sendResponseToUiMessage", () => {
});
describe("applyHttpSendOutput", () => {
it("stores guidance for a blocked send and clears it after normal chat resumes", () => {
const guidance = {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "insufficientCredits" as const,
mode: "guide" as const,
title: "Keep talking with Elio",
copy: "Baby, I want to keep talking with you.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"] as const,
target: "subscription" as const,
ruleId: "payment_guidance_insufficientCredits",
};
const blocked = applyHttpSendOutput(makeChatState(), {
response: makeResponse({
reply: "",
canSendMessage: false,
paymentGuidance: guidance,
}),
reply: null,
});
expect(blocked.paymentGuidance).toEqual(guidance);
const resumed = applyHttpSendOutput(
makeChatState({ paymentGuidance: guidance }),
{
response: makeResponse({ reply: "Back to our conversation." }),
reply: sendResponseToUiMessage(
makeResponse({ reply: "Back to our conversation." }),
),
},
);
expect(resumed.paymentGuidance).toBeNull();
});
it("stores insufficient credit state and removes failed optimistic messages", () => {
const context = makeChatState({
messages: [
+2
View File
@@ -20,6 +20,7 @@ interface ChatState {
canSendMessage: boolean;
upgradePromptVisible: boolean;
upgradeReason: MachineContext["upgradeReason"];
paymentGuidance: MachineContext["paymentGuidance"];
/** True as soon as a local snapshot or the first network result is renderable. */
historyLoaded: boolean;
/** True after the network history request has completed or failed. */
@@ -94,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
canSendMessage: state.context.canSendMessage,
upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason,
paymentGuidance: state.context.paymentGuidance,
historyLoaded: state.context.historyLoaded,
networkHistoryLoaded: state.context.networkHistoryLoaded,
hasMoreHistory:
+6 -1
View File
@@ -1,6 +1,6 @@
import type { UiMessage } from "@/stores/chat/ui-message";
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat";
import type { ChatLockType, PaymentGuidance } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import {
DEFAULT_CHARACTER,
@@ -21,6 +21,7 @@ export interface ChatUnlockMessageRequest {
export interface ChatUnlockPaywallRequest
extends ChatUnlockMessageRequest {
paymentGuidance?: PaymentGuidance | null;
promotion?: PendingChatPromotion;
reason: string;
creditBalance: number;
@@ -39,6 +40,7 @@ export interface ChatState {
pendingReplyCount: number;
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
paymentGuidance: PaymentGuidance | null;
canSendMessage: boolean;
creditBalance: number;
creditsCharged: number;
@@ -56,6 +58,7 @@ export interface ChatState {
unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number;
unlockHistoryError: string | null;
unlockHistoryPaymentGuidance: PaymentGuidance | null;
unlockingMessage: ChatUnlockMessageRequest | null;
unlockMessageError: string | null;
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
@@ -81,6 +84,7 @@ export function createInitialChatState(
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
historyLoaded: false,
networkHistoryLoaded: false,
historyTotal: 0,
@@ -91,6 +95,7 @@ export function createInitialChatState(
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
+7 -5
View File
@@ -74,11 +74,13 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
...(response.audioUrl && !response.lockDetail.locked
? { audioUrl: response.audioUrl }
: {}),
...(response.chatAction
? { chatAction: response.chatAction }
: response.commercialAction
? { commercialAction: response.commercialAction }
: {}),
...(response.paymentGuidance
? { paymentGuidance: response.paymentGuidance }
: response.chatAction
? { chatAction: response.chatAction }
: response.commercialAction
? { commercialAction: response.commercialAction }
: {}),
...deriveUiLockFields(response.lockDetail, response.image.url),
};
}
+3
View File
@@ -53,6 +53,7 @@ export function applyHttpSendOutput(
...finishPendingReply(context),
upgradePromptVisible: true,
upgradeReason,
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
@@ -60,6 +61,7 @@ export function applyHttpSendOutput(
if (!reply) {
return {
...finishPendingReply(context),
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
@@ -69,6 +71,7 @@ export function applyHttpSendOutput(
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
paymentGuidance: response.paymentGuidance,
...sendCapabilityState,
};
}
+3
View File
@@ -1,6 +1,7 @@
import { fromPromise } from "xstate";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import type { PaymentGuidance } from "@/data/schemas/chat";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
@@ -21,6 +22,7 @@ export interface UnlockHistoryOutput {
reason: string;
shortfallCredits: number;
messages: UiMessage[];
paymentGuidance?: PaymentGuidance | null;
}
export const unlockHistoryActor = fromPromise<
@@ -50,6 +52,7 @@ export const unlockHistoryActor = fromPromise<
reason: unlockResult.data.reason,
shortfallCredits: unlockResult.data.shortfallCredits,
messages: history.messages,
paymentGuidance: unlockResult.data.paymentGuidance,
};
});
+5 -2
View File
@@ -45,7 +45,8 @@ const appendGuestUserMessageAction = historyMachineSetup.assign(
],
outgoingMessageRevision: context.outgoingMessageRevision + 1,
upgradePromptVisible: false,
upgradeReason: null,
upgradeReason: null,
paymentGuidance: null,
};
},
);
@@ -73,7 +74,8 @@ const appendUserMessageAction = historyMachineSetup.assign(
],
outgoingMessageRevision: context.outgoingMessageRevision + 1,
upgradePromptVisible: false,
upgradeReason: null,
upgradeReason: null,
paymentGuidance: null,
};
},
);
@@ -124,6 +126,7 @@ const clearUpgradePromptAction = historyMachineSetup.assign(() => ({
canSendMessage: true,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
}));
const appendGuestUserImageAction = historyMachineSetup.assign(
+11
View File
@@ -22,6 +22,7 @@ const showUnlockHistoryPromptAction = sendMachineSetup.assign(
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}),
);
@@ -29,12 +30,14 @@ const dismissUnlockHistoryPromptAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}));
const markUnlockHistoryStartedAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
unlockHistoryPaymentGuidance: null,
}));
const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
@@ -76,6 +79,7 @@ const applyUnlockMessageSucceededAction = sendMachineSetup.assign(
creditsCharged: output.response.creditsCharged,
requiredCredits: 0,
shortfallCredits: 0,
paymentGuidance: null,
};
},
);
@@ -112,11 +116,15 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
...(output.response.paymentGuidance
? { paymentGuidance: output.response.paymentGuidance }
: {}),
}
: null,
creditBalance: output.response.creditBalance,
requiredCredits: output.response.requiredCredits,
shortfallCredits: output.response.shortfallCredits,
paymentGuidance: output.response.paymentGuidance,
};
},
);
@@ -191,6 +199,9 @@ const applyUnlockHistoryOutputAction =
unlockHistoryError: insufficientBalance
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
: null,
unlockHistoryPaymentGuidance: insufficientBalance
? event.output.paymentGuidance ?? null
: null,
};
});
+6 -1
View File
@@ -1,5 +1,9 @@
import { z } from "zod";
import { ChatActionSchema, CommercialActionSchema } from "@/data/schemas/chat";
import {
ChatActionSchema,
CommercialActionSchema,
PaymentGuidanceSchema,
} from "@/data/schemas/chat";
export const UiMessageSchema = z.object({
displayId: z.string().min(1),
@@ -19,6 +23,7 @@ export const UiMessageSchema = z.object({
privateMessageHint: z.string().nullable().optional(),
commercialAction: CommercialActionSchema.nullable().optional(),
chatAction: ChatActionSchema.nullable().optional(),
paymentGuidance: PaymentGuidanceSchema.nullable().optional(),
});
export type UiMessage = z.infer<typeof UiMessageSchema>;
@@ -203,6 +203,9 @@ export const privateZonePostMachine = machineSetup.createMachine({
currentCredits:
event.output.currentCredits || event.output.creditBalance,
shortfallCredits: event.output.shortfallCredits,
...(event.output.paymentGuidance
? { paymentGuidance: event.output.paymentGuidance }
: {}),
},
})),
},
@@ -1,4 +1,5 @@
import type { PrivateZoneVideoPost } from "@/data/schemas/private-zone";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface PrivateZonePostPaywallRequest {
postId: string;
@@ -6,6 +7,7 @@ export interface PrivateZonePostPaywallRequest {
requiredCredits: number;
currentCredits: number;
shortfallCredits: number;
paymentGuidance?: PaymentGuidance | null;
}
export interface PrivateZonePostState {
+3
View File
@@ -37,5 +37,8 @@ export function toPaywallRequest(
requiredCredits: response.requiredCredits || response.unlockCost,
currentCredits: response.creditBalance,
shortfallCredits: response.shortfallCredits,
...(response.paymentGuidance
? { paymentGuidance: response.paymentGuidance }
: {}),
};
}
@@ -1,5 +1,6 @@
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import type { PrivateAlbum } from "@/data/schemas/private-zone";
import type { PaymentGuidance } from "@/data/schemas/chat";
export interface PrivateZoneUnlockPaywallRequest {
albumId: string;
@@ -7,6 +8,7 @@ export interface PrivateZoneUnlockPaywallRequest {
requiredCredits: number;
currentCredits: number;
shortfallCredits: number;
paymentGuidance?: PaymentGuidance | null;
}
export interface PrivateZoneState {