-
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 {