From 8c0d1ad3ce48004d6e0854e4b3db8c4915e492fa Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 18:14:04 +0800 Subject: [PATCH] feat(chat): render role-bound payment guidance --- src/app/chat/chat-screen.tsx | 2 + .../__tests__/tailwind-components.test.tsx | 59 ++++++++++ src/app/chat/components/chat-area.tsx | 1 + .../chat-insufficient-credits-banner.tsx | 16 ++- .../chat/components/chat-unlock-dialogs.tsx | 2 + .../chat/components/history-unlock-dialog.tsx | 27 ++++- src/app/chat/components/index.ts | 1 + .../insufficient-credits-dialog.tsx | 19 +++- src/app/chat/components/message-bubble.tsx | 5 +- src/app/chat/components/message-content.tsx | 13 ++- .../chat/components/payment-guidance-card.tsx | 105 ++++++++++++++++++ .../hooks/use-chat-message-limit-banner.ts | 48 +++++++- .../chat/hooks/use-chat-unlock-coordinator.ts | 74 +++++++++++- .../use-private-zone-flow-navigation.test.tsx | 5 +- src/app/private-zone/private-zone-screen.tsx | 25 ++++- src/app/private-zone/use-private-zone-flow.ts | 30 ++++- .../subscription-screen-flow.test.tsx | 19 ++++ .../components/subscription-screen.module.css | 23 ++++ src/app/subscription/subscription-screen.tsx | 30 ++++- src/app/tip/tip-screen.tsx | 2 +- src/app/tip/use-tip-support-prompt.ts | 16 ++- .../chat-websocket-payment-guidance.test.ts | 54 +++++++++ src/core/net/chat-websocket.ts | 10 +- .../chat/__tests__/payment_guidance.test.ts | 80 +++++++++++++ src/data/schemas/chat/chat_payloads.ts | 11 +- .../chat/response/chat_send_response.ts | 36 ++++++ .../chat/response/unlock_history_response.ts | 2 + .../chat/response/unlock_private_response.ts | 2 + .../__tests__/private_album.test.ts | 22 ++++ .../schemas/private-zone/private_zone_post.ts | 2 + .../response/private_album_unlock_response.ts | 2 + .../tip/__tests__/tip_support_prompts.test.ts | 13 +++ src/lib/tip/tip_support_prompts.ts | 68 ++++++------ src/router/navigation-types.ts | 2 + src/router/use-app-navigator.ts | 2 + src/router/use-global-app-navigator.ts | 2 + .../chat/__tests__/chat-helpers.test.ts | 71 ++++++++++++ src/stores/chat/chat-context.tsx | 2 + src/stores/chat/chat-state.ts | 7 +- src/stores/chat/helper/message-mappers.ts | 12 +- src/stores/chat/helper/send-state.ts | 3 + src/stores/chat/machine/actors/unlock.ts | 3 + src/stores/chat/machine/send-flow.ts | 7 +- src/stores/chat/machine/unlock-flow.ts | 11 ++ src/stores/chat/ui-message.ts | 7 +- .../private-zone-post-machine.ts | 3 + .../private-zone-post-state.ts | 2 + src/stores/private-zone/helper/unlock.ts | 3 + src/stores/private-zone/private-zone-state.ts | 2 + 49 files changed, 882 insertions(+), 81 deletions(-) create mode 100644 src/app/chat/components/payment-guidance-card.tsx create mode 100644 src/core/net/__tests__/chat-websocket-payment-guidance.test.ts create mode 100644 src/data/schemas/chat/__tests__/payment_guidance.test.ts diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index e75a8a75..f61caaf3 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -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} /> ) : ( diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx index e44cb348..d77c447a 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -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("
"); 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( + , + ); + const mismatchHtml = renderWithCharacter( + , + ); + const careHtml = renderWithCharacter( + , + ); + + expect(guideHtml).toContain('data-payment-guidance-mode="guide"'); + expect(guideHtml).toContain("View VIP & 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(); const memberHtml = renderWithCharacter( diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 73313964..c7b4698f 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -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 diff --git a/src/app/chat/components/chat-insufficient-credits-banner.tsx b/src/app/chat/components/chat-insufficient-credits-banner.tsx index 7567f29b..f6132222 100644 --- a/src/app/chat/components/chat-insufficient-credits-banner.tsx +++ b/src/app/chat/components/chat-insufficient-credits-banner.tsx @@ -17,11 +17,13 @@ * - 不直接管理 state machine(chat 机器不感知 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} >

{titleLines.map((line, index) => ( @@ -67,7 +72,12 @@ export function ChatInsufficientCreditsBanner({ {description}

) : null} - + : null} ); } diff --git a/src/app/chat/components/chat-unlock-dialogs.tsx b/src/app/chat/components/chat-unlock-dialogs.tsx index 0263d980..3bb39a9e 100644 --- a/src/app/chat/components/chat-unlock-dialogs.tsx +++ b/src/app/chat/components/chat-unlock-dialogs.tsx @@ -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} /> diff --git a/src/app/chat/components/history-unlock-dialog.tsx b/src/app/chat/components/history-unlock-dialog.tsx index b9f27d76..19b9664b 100644 --- a/src/app/chat/components/history-unlock-dialog.tsx +++ b/src/app/chat/components/history-unlock-dialog.tsx @@ -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 (
- Unlock previous messages? + {activeGuidance?.title ?? "Unlock previous messages?"}

- 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. + + )}

{errorMessage ? (

@@ -51,7 +64,7 @@ export function HistoryUnlockDialog({ > Later - + {isLoading + ? "Unlocking..." + : activeGuidance?.ctaLabel ?? "Unlock now"} + : null}

diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index c6442773..9f4b3c9c 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -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"; diff --git a/src/app/chat/components/insufficient-credits-dialog.tsx b/src/app/chat/components/insufficient-credits-dialog.tsx index 057efc71..9f25073f 100644 --- a/src/app/chat/components/insufficient-credits-dialog.tsx +++ b/src/app/chat/components/insufficient-credits-dialog.tsx @@ -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 (
- Not enough credits + {activeGuidance?.title ?? "Not enough credits"}

- 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."}

{showCreditDetail ? ( @@ -70,15 +79,15 @@ export function InsufficientCreditsDialog({ > Later - + {activeGuidance?.ctaLabel ?? "Top up now"} + : null}
diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 76d9622c..a6aba60d 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -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} diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index e8e24a4b..59319ced 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -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 ? ( + + ) : isFromAI && chatAction ? ( state.context.isVip); + const [dismissed, setDismissed] = useState(false); + const viewedIdRef = useRef(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 ( + + ); +} diff --git a/src/app/chat/hooks/use-chat-message-limit-banner.ts b/src/app/chat/hooks/use-chat-message-limit-banner.ts index 1bc5d6c7..d7950832 100644 --- a/src/app/chat/hooks/use-chat-message-limit-banner.ts +++ b/src/app/chat/hooks/use-chat-message-limit-banner.ts @@ -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, }; diff --git a/src/app/chat/hooks/use-chat-unlock-coordinator.ts b/src/app/chat/hooks/use-chat-unlock-coordinator.ts index 93c74b43..c2a3c510 100644 --- a/src/app/chat/hooks/use-chat-unlock-coordinator.ts +++ b/src/app/chat/hooks/use-chat-unlock-coordinator.ts @@ -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, diff --git a/src/app/private-zone/__tests__/use-private-zone-flow-navigation.test.tsx b/src/app/private-zone/__tests__/use-private-zone-flow-navigation.test.tsx index c5fc2043..993b31f0 100644 --- a/src/app/private-zone/__tests__/use-private-zone-flow-navigation.test.tsx +++ b/src/app/private-zone/__tests__/use-private-zone-flow-navigation.test.tsx @@ -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>(); 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; } diff --git a/src/app/private-zone/private-zone-screen.tsx b/src/app/private-zone/private-zone-screen.tsx index 187a70ba..64c75b8c 100644 --- a/src/app/private-zone/private-zone-screen.tsx +++ b/src/app/private-zone/private-zone-screen.tsx @@ -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", diff --git a/src/app/private-zone/use-private-zone-flow.ts b/src/app/private-zone/use-private-zone-flow.ts index 2c13156b..7fb1b8fb 100644 --- a/src/app/private-zone/use-private-zone-flow.ts +++ b/src/app/private-zone/use-private-zone-flow.ts @@ -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; @@ -25,6 +29,7 @@ export interface UsePrivateZoneUnlockPaywallNavigationInput { loginStatus: LoginStatus; roomDispatch: Dispatch; 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, diff --git a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx index 4925b6a4..51129ad1 100644 --- a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx +++ b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx @@ -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( + , + ), + ); + + 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 { diff --git a/src/app/subscription/components/subscription-screen.module.css b/src/app/subscription/components/subscription-screen.module.css index db4b9fe7..82e04f0a 100644 --- a/src/app/subscription/components/subscription-screen.module.css +++ b/src/app/subscription/components/subscription-screen.module.css @@ -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; diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 2a6ef8a6..7b8e3b37 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -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({

) : null} + {chatActionId ? ( +
+

Keep talking with {sourceCharacter.shortName}

+

+ 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. +

+
+ ) : null} + {firstRechargeOffer ? (
Private offer
-

Elio got this price for you

+

+ {sourceCharacter.shortName} got this price for you +

{payment.commercialOffer.message || `Your ${payment.commercialOffer.discountPercent}% discount is already applied below.`} diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index ebf44d01..77f28b98 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -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, diff --git a/src/app/tip/use-tip-support-prompt.ts b/src/app/tip/use-tip-support-prompt.ts index 8aacac2f..281c630f 100644 --- a/src/app/tip/use-tip-support-prompt.ts +++ b/src/app/tip/use-tip-support-prompt.ts @@ -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 | 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, }; } diff --git a/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts b/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts new file mode 100644 index 00000000..a902800e --- /dev/null +++ b/src/core/net/__tests__/chat-websocket-payment-guidance.test.ts @@ -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)); +} diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 8eabf692..755a6c59 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -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 & { 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; diff --git a/src/data/schemas/chat/__tests__/payment_guidance.test.ts b/src/data/schemas/chat/__tests__/payment_guidance.test.ts new file mode 100644 index 00000000..dd1bdfb3 --- /dev/null +++ b/src/data/schemas/chat/__tests__/payment_guidance.test.ts @@ -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"); + }); +}); diff --git a/src/data/schemas/chat/chat_payloads.ts b/src/data/schemas/chat/chat_payloads.ts index 284fd539..f966f145 100644 --- a/src/data/schemas/chat/chat_payloads.ts +++ b/src/data/schemas/chat/chat_payloads.ts @@ -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(); diff --git a/src/data/schemas/chat/response/chat_send_response.ts b/src/data/schemas/chat/response/chat_send_response.ts index 304571ad..603d828d 100644 --- a/src/data/schemas/chat/response/chat_send_response.ts +++ b/src/data/schemas/chat/response/chat_send_response.ts @@ -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; + 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(); diff --git a/src/data/schemas/chat/response/unlock_history_response.ts b/src/data/schemas/chat/response/unlock_history_response.ts index 2c6cbcd1..4aa2699d 100644 --- a/src/data/schemas/chat/response/unlock_history_response.ts +++ b/src/data/schemas/chat/response/unlock_history_response.ts @@ -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(); diff --git a/src/data/schemas/chat/response/unlock_private_response.ts b/src/data/schemas/chat/response/unlock_private_response.ts index 7fff4d26..f9bfd965 100644 --- a/src/data/schemas/chat/response/unlock_private_response.ts +++ b/src/data/schemas/chat/response/unlock_private_response.ts @@ -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(); diff --git a/src/data/schemas/private-zone/__tests__/private_album.test.ts b/src/data/schemas/private-zone/__tests__/private_album.test.ts index 45536b5f..9f6e3efe 100644 --- a/src/data/schemas/private-zone/__tests__/private_album.test.ts +++ b/src/data/schemas/private-zone/__tests__/private_album.test.ts @@ -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], diff --git a/src/data/schemas/private-zone/private_zone_post.ts b/src/data/schemas/private-zone/private_zone_post.ts index 25bbfedd..e44358f6 100644 --- a/src/data/schemas/private-zone/private_zone_post.ts +++ b/src/data/schemas/private-zone/private_zone_post.ts @@ -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(); diff --git a/src/data/schemas/private-zone/response/private_album_unlock_response.ts b/src/data/schemas/private-zone/response/private_album_unlock_response.ts index c71e7ef6..7d227500 100644 --- a/src/data/schemas/private-zone/response/private_album_unlock_response.ts +++ b/src/data/schemas/private-zone/response/private_album_unlock_response.ts @@ -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(); diff --git a/src/lib/tip/__tests__/tip_support_prompts.test.ts b/src/lib/tip/__tests__/tip_support_prompts.test.ts index c6c72971..d1705bbb 100644 --- a/src/lib/tip/__tests__/tip_support_prompts.test.ts +++ b/src/lib/tip/__tests__/tip_support_prompts.test.ts @@ -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); + }); }); diff --git a/src/lib/tip/tip_support_prompts.ts b/src/lib/tip/tip_support_prompts.ts index f8f01baa..895bfb06 100644 --- a/src/lib/tip/tip_support_prompts.ts +++ b/src/lib/tip/tip_support_prompts.ts @@ -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, + ), }; } diff --git a/src/router/navigation-types.ts b/src/router/navigation-types.ts index 9f28ed1e..7b4c6b30 100644 --- a/src/router/navigation-types.ts +++ b/src/router/navigation-types.ts @@ -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; } diff --git a/src/router/use-app-navigator.ts b/src/router/use-app-navigator.ts index b066f994..b486e118 100644 --- a/src/router/use-app-navigator.ts +++ b/src/router/use-app-navigator.ts @@ -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, }); })(); }, diff --git a/src/router/use-global-app-navigator.ts b/src/router/use-global-app-navigator.ts index c750090a..8054b7e6 100644 --- a/src/router/use-global-app-navigator.ts +++ b/src/router/use-global-app-navigator.ts @@ -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); diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index 0e62e007..694074f6 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -54,6 +54,7 @@ function makeChatState(overrides: Partial = {}): ChatState { creditsCharged: 0, requiredCredits: 0, shortfallCredits: 0, + paymentGuidance: null, historyLoaded: true, networkHistoryLoaded: true, historyTotal: 0, @@ -64,6 +65,7 @@ function makeChatState(overrides: Partial = {}): ChatState { unlockHistoryPromptVisible: false, lockedHistoryCount: 0, unlockHistoryError: null, + unlockHistoryPaymentGuidance: null, unlockingMessage: null, unlockMessageError: null, unlockPaywallRequest: null, @@ -72,6 +74,39 @@ function makeChatState(overrides: Partial = {}): 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: [ diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 66403da2..535d338f 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -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: diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index ff5bd436..92e4c7c5 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -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, diff --git a/src/stores/chat/helper/message-mappers.ts b/src/stores/chat/helper/message-mappers.ts index bfa59034..ee2f5a77 100644 --- a/src/stores/chat/helper/message-mappers.ts +++ b/src/stores/chat/helper/message-mappers.ts @@ -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), }; } diff --git a/src/stores/chat/helper/send-state.ts b/src/stores/chat/helper/send-state.ts index c12d75ac..c9a4b3b7 100644 --- a/src/stores/chat/helper/send-state.ts +++ b/src/stores/chat/helper/send-state.ts @@ -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, }; } diff --git a/src/stores/chat/machine/actors/unlock.ts b/src/stores/chat/machine/actors/unlock.ts index c89434cb..9c8f4ac9 100644 --- a/src/stores/chat/machine/actors/unlock.ts +++ b/src/stores/chat/machine/actors/unlock.ts @@ -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, }; }); diff --git a/src/stores/chat/machine/send-flow.ts b/src/stores/chat/machine/send-flow.ts index 5c2a8552..c6816ea8 100644 --- a/src/stores/chat/machine/send-flow.ts +++ b/src/stores/chat/machine/send-flow.ts @@ -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( diff --git a/src/stores/chat/machine/unlock-flow.ts b/src/stores/chat/machine/unlock-flow.ts index e25026d3..e13c9147 100644 --- a/src/stores/chat/machine/unlock-flow.ts +++ b/src/stores/chat/machine/unlock-flow.ts @@ -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, }; }); diff --git a/src/stores/chat/ui-message.ts b/src/stores/chat/ui-message.ts index 4dfd0175..a52d2134 100644 --- a/src/stores/chat/ui-message.ts +++ b/src/stores/chat/ui-message.ts @@ -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; diff --git a/src/stores/private-zone-posts/private-zone-post-machine.ts b/src/stores/private-zone-posts/private-zone-post-machine.ts index 77703ffb..fa6a43ed 100644 --- a/src/stores/private-zone-posts/private-zone-post-machine.ts +++ b/src/stores/private-zone-posts/private-zone-post-machine.ts @@ -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 } + : {}), }, })), }, diff --git a/src/stores/private-zone-posts/private-zone-post-state.ts b/src/stores/private-zone-posts/private-zone-post-state.ts index 4b256917..a55f216c 100644 --- a/src/stores/private-zone-posts/private-zone-post-state.ts +++ b/src/stores/private-zone-posts/private-zone-post-state.ts @@ -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 { diff --git a/src/stores/private-zone/helper/unlock.ts b/src/stores/private-zone/helper/unlock.ts index c31693ea..2512d16a 100644 --- a/src/stores/private-zone/helper/unlock.ts +++ b/src/stores/private-zone/helper/unlock.ts @@ -37,5 +37,8 @@ export function toPaywallRequest( requiredCredits: response.requiredCredits || response.unlockCost, currentCredits: response.creditBalance, shortfallCredits: response.shortfallCredits, + ...(response.paymentGuidance + ? { paymentGuidance: response.paymentGuidance } + : {}), }; } diff --git a/src/stores/private-zone/private-zone-state.ts b/src/stores/private-zone/private-zone-state.ts index af0eda66..ea83ff14 100644 --- a/src/stores/private-zone/private-zone-state.ts +++ b/src/stores/private-zone/private-zone-state.ts @@ -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 {