feat(chat): render role-bound payment guidance
This commit is contained in:
@@ -54,6 +54,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
paymentGuidance: null,
|
||||
historyLoaded: true,
|
||||
networkHistoryLoaded: true,
|
||||
historyTotal: 0,
|
||||
@@ -64,6 +65,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
unlockHistoryPromptVisible: false,
|
||||
lockedHistoryCount: 0,
|
||||
unlockHistoryError: null,
|
||||
unlockHistoryPaymentGuidance: null,
|
||||
unlockingMessage: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
@@ -72,6 +74,39 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
}
|
||||
|
||||
describe("sendResponseToUiMessage", () => {
|
||||
it("prefers payment guidance over other commercial actions", () => {
|
||||
const message = sendResponseToUiMessage(
|
||||
makeResponse({
|
||||
paymentGuidance: {
|
||||
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
|
||||
characterId: "elio",
|
||||
characterName: "Elio Silvestri",
|
||||
scene: "insufficientCredits",
|
||||
mode: "guide",
|
||||
title: "Keep talking with Elio",
|
||||
copy: "Baby, I want to keep talking with you.",
|
||||
ctaLabel: "View VIP & credit options",
|
||||
purchaseOptions: ["vip", "topup"],
|
||||
target: "subscription",
|
||||
ruleId: "payment_guidance_insufficientCredits",
|
||||
},
|
||||
chatAction: {
|
||||
actionId: "chat-action-1",
|
||||
kind: "commercial",
|
||||
type: "giftOffer",
|
||||
copy: "View gifts.",
|
||||
ctaLabel: "View gifts",
|
||||
ruleId: "legacy",
|
||||
orderId: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(message.paymentGuidance?.characterId).toBe("elio");
|
||||
expect(message.chatAction).toBeUndefined();
|
||||
expect(message.commercialAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a typed commercial action on the assistant message", () => {
|
||||
const message = sendResponseToUiMessage(
|
||||
makeResponse({
|
||||
@@ -228,6 +263,42 @@ describe("sendResponseToUiMessage", () => {
|
||||
});
|
||||
|
||||
describe("applyHttpSendOutput", () => {
|
||||
it("stores guidance for a blocked send and clears it after normal chat resumes", () => {
|
||||
const guidance = {
|
||||
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
|
||||
characterId: "elio",
|
||||
characterName: "Elio Silvestri",
|
||||
scene: "insufficientCredits" as const,
|
||||
mode: "guide" as const,
|
||||
title: "Keep talking with Elio",
|
||||
copy: "Baby, I want to keep talking with you.",
|
||||
ctaLabel: "View VIP & credit options",
|
||||
purchaseOptions: ["vip", "topup"] as const,
|
||||
target: "subscription" as const,
|
||||
ruleId: "payment_guidance_insufficientCredits",
|
||||
};
|
||||
const blocked = applyHttpSendOutput(makeChatState(), {
|
||||
response: makeResponse({
|
||||
reply: "",
|
||||
canSendMessage: false,
|
||||
paymentGuidance: guidance,
|
||||
}),
|
||||
reply: null,
|
||||
});
|
||||
expect(blocked.paymentGuidance).toEqual(guidance);
|
||||
|
||||
const resumed = applyHttpSendOutput(
|
||||
makeChatState({ paymentGuidance: guidance }),
|
||||
{
|
||||
response: makeResponse({ reply: "Back to our conversation." }),
|
||||
reply: sendResponseToUiMessage(
|
||||
makeResponse({ reply: "Back to our conversation." }),
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(resumed.paymentGuidance).toBeNull();
|
||||
});
|
||||
|
||||
it("stores insufficient credit state and removes failed optimistic messages", () => {
|
||||
const context = makeChatState({
|
||||
messages: [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { ChatActionSchema, CommercialActionSchema } from "@/data/schemas/chat";
|
||||
import {
|
||||
ChatActionSchema,
|
||||
CommercialActionSchema,
|
||||
PaymentGuidanceSchema,
|
||||
} from "@/data/schemas/chat";
|
||||
|
||||
export const UiMessageSchema = z.object({
|
||||
displayId: z.string().min(1),
|
||||
@@ -19,6 +23,7 @@ export const UiMessageSchema = z.object({
|
||||
privateMessageHint: z.string().nullable().optional(),
|
||||
commercialAction: CommercialActionSchema.nullable().optional(),
|
||||
chatAction: ChatActionSchema.nullable().optional(),
|
||||
paymentGuidance: PaymentGuidanceSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export type UiMessage = z.infer<typeof UiMessageSchema>;
|
||||
|
||||
Reference in New Issue
Block a user