feat(chat): render role-bound payment guidance
Docker Image / Build and Push Docker Image (push) Successful in 5m2s
Docker Image / Build and Push Docker Image (push) Successful in 5m2s
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, schemaOr, stringOrNull } from "../nullable-defaults";
|
||||
import {
|
||||
booleanOrFalse,
|
||||
schemaOr,
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
|
||||
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
|
||||
|
||||
@@ -23,7 +27,12 @@ export const ChatImageSchema = schemaOr(
|
||||
export const ChatLockDetailSchema = schemaOr(
|
||||
z.object({
|
||||
locked: booleanOrFalse,
|
||||
showContent: z.boolean().nullish(),
|
||||
showUpgrade: z.boolean().nullish(),
|
||||
reason: stringOrNull,
|
||||
hint: z.string().nullish(),
|
||||
actionLabel: z.string().nullish(),
|
||||
detail: z.record(z.string(), z.unknown()).nullish(),
|
||||
}),
|
||||
CHAT_LOCK_DETAIL_DEFAULTS,
|
||||
).readonly();
|
||||
|
||||
@@ -13,6 +13,41 @@ import {
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
|
||||
import { ChatActionSchema } from "../chat_action";
|
||||
|
||||
export const PaymentGuidanceSceneSchema = z.enum([
|
||||
"vip",
|
||||
"insufficientCredits",
|
||||
"chatInterrupted",
|
||||
"voiceLocked",
|
||||
"imageLocked",
|
||||
"privateMessageLocked",
|
||||
"historyLocked",
|
||||
"privateZoneLocked",
|
||||
"unknownLock",
|
||||
"whyPay",
|
||||
"leavingAfterCredits",
|
||||
"paymentIssue",
|
||||
"externalRisk",
|
||||
"purchaseOptions",
|
||||
]);
|
||||
|
||||
export const PaymentGuidanceSchema = z
|
||||
.object({
|
||||
guidanceId: z.string().min(1),
|
||||
characterId: z.string().min(1),
|
||||
characterName: z.string().min(1),
|
||||
scene: PaymentGuidanceSceneSchema,
|
||||
mode: z.enum(["guide", "careOnly", "issueSupport", "riskReminder"]),
|
||||
title: z.string().nullable().default(null),
|
||||
copy: z.string().min(1),
|
||||
ctaLabel: z.string().nullable().default(null),
|
||||
purchaseOptions: z.array(z.enum(["vip", "topup"])).readonly(),
|
||||
target: z.enum(["subscription", "giftCatalog"]).nullable().default(null),
|
||||
ruleId: z.string().min(1),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PaymentGuidance = z.output<typeof PaymentGuidanceSchema>;
|
||||
|
||||
export const CommercialActionSchema = z
|
||||
.object({
|
||||
actionId: z.string().min(1),
|
||||
@@ -42,6 +77,7 @@ export const ChatSendResponseSchema = z
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
commercialAction: CommercialActionSchema.nullish().default(null),
|
||||
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
|
||||
chatAction: ChatActionSchema.nullish().default(null),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
numberOrZero,
|
||||
recordOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
import { PaymentGuidanceSchema } from "./chat_send_response";
|
||||
|
||||
export const UnlockHistoryCostsByMessageSchema = recordOrEmpty(z.number());
|
||||
|
||||
@@ -33,6 +34,7 @@ export const UnlockHistoryResponseSchema = z
|
||||
shortfallCredits: numberOrZero,
|
||||
costsByMessage: UnlockHistoryCostsByMessageSchema,
|
||||
messageIds: arrayOrEmpty(z.string()),
|
||||
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../nullable-defaults";
|
||||
import { ChatLockTypeSchema } from "../chat_lock_type";
|
||||
import { ChatImageSchema } from "../chat_payloads";
|
||||
import { PaymentGuidanceSchema } from "./chat_send_response";
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应。
|
||||
@@ -28,6 +29,7 @@ export const UnlockPrivateResponseSchema = z
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -39,6 +39,28 @@ const lockedAlbum = {
|
||||
};
|
||||
|
||||
describe("Private Album schema models", () => {
|
||||
it("keeps shared payment guidance on an insufficient-credit response", () => {
|
||||
const response = PrivateAlbumUnlockResponseSchema.parse({
|
||||
albumId: ALBUM_ID,
|
||||
reason: "insufficient_credits",
|
||||
paymentGuidance: {
|
||||
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
|
||||
characterId: "elio",
|
||||
characterName: "Elio Silvestri",
|
||||
scene: "privateZoneLocked",
|
||||
mode: "guide",
|
||||
title: "Keep talking with Elio",
|
||||
copy: "Baby, this album needs credits.",
|
||||
ctaLabel: "View VIP & credit options",
|
||||
purchaseOptions: ["vip", "topup"],
|
||||
target: "subscription",
|
||||
ruleId: "payment_guidance_privateZoneLocked",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.paymentGuidance?.characterId).toBe("elio");
|
||||
});
|
||||
|
||||
it("keeps a locked album cover URL while preserving the lock state", () => {
|
||||
const response = PrivateAlbumsResponseSchema.parse({
|
||||
items: [lockedAlbum],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
stringOrEmpty,
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
import { PaymentGuidanceSchema } from "../chat";
|
||||
|
||||
export const PrivateZoneVideoPostSchema = z
|
||||
.object({
|
||||
@@ -63,6 +64,7 @@ export const PrivateZonePostUnlockResponseSchema = z
|
||||
shortfallCredits: numberOrZero,
|
||||
creditBalance: numberOrZero,
|
||||
post: schemaOrNull(PrivateZoneVideoPostSchema),
|
||||
paymentGuidance: PaymentGuidanceSchema.nullish(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
import { PrivateAlbumImageSchema } from "../private_album";
|
||||
import { PaymentGuidanceSchema } from "../../chat";
|
||||
|
||||
export const PrivateAlbumUnlockReasonSchema = z.enum([
|
||||
"ok",
|
||||
@@ -30,6 +31,7 @@ export const PrivateAlbumUnlockResponseSchema = z
|
||||
creditBalance: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
images: arrayOrEmpty(PrivateAlbumImageSchema),
|
||||
paymentGuidance: PaymentGuidanceSchema.nullish(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user