feat(chat): add support action cards and live entitlements

This commit is contained in:
Codex
2026-07-24 17:59:57 +08:00
parent d746d04503
commit ff2759cf0f
52 changed files with 1151 additions and 40 deletions
@@ -96,6 +96,33 @@ describe("sendResponseToUiMessage", () => {
});
});
it("prefers the new chat action over the legacy commercial action", () => {
const message = sendResponseToUiMessage(
makeResponse({
chatAction: {
actionId: "chat-action-1",
kind: "support",
type: "openFeedback",
copy: "Share the screenshot here.",
ctaLabel: "Open Feedback",
ruleId: null,
orderId: null,
},
commercialAction: {
actionId: "legacy-action-1",
type: "giftOffer",
copy: "View gifts.",
ctaLabel: "View gifts",
target: "giftCatalog",
ruleId: "legacy-rule",
},
}),
);
expect(message.chatAction?.actionId).toBe("chat-action-1");
expect(message.commercialAction).toBeUndefined();
});
it("maps locked voice messages without treating them as private text", () => {
const message = sendResponseToUiMessage(
makeResponse({
+5 -3
View File
@@ -74,9 +74,11 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
...(response.audioUrl && !response.lockDetail.locked
? { audioUrl: response.audioUrl }
: {}),
...(response.commercialAction
? { commercialAction: response.commercialAction }
: {}),
...(response.chatAction
? { chatAction: response.chatAction }
: response.commercialAction
? { commercialAction: response.commercialAction }
: {}),
...deriveUiLockFields(response.lockDetail, response.image.url),
};
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { CommercialActionSchema } from "@/data/schemas/chat";
import { ChatActionSchema, CommercialActionSchema } from "@/data/schemas/chat";
export const UiMessageSchema = z.object({
displayId: z.string().min(1),
@@ -18,6 +18,7 @@ export const UiMessageSchema = z.object({
lockedPrivate: z.boolean().nullable().optional(),
privateMessageHint: z.string().nullable().optional(),
commercialAction: CommercialActionSchema.nullable().optional(),
chatAction: ChatActionSchema.nullable().optional(),
});
export type UiMessage = z.infer<typeof UiMessageSchema>;
@@ -28,6 +28,7 @@ export interface CreateOrderInput {
autoRenew: boolean;
recipientCharacterId?: string;
commercialOfferId?: string;
chatActionId?: string;
}
export type CreateOrderSpy = (input: CreateOrderInput) => void;
@@ -105,6 +105,30 @@ describe("payment order flow", () => {
actor.stop();
});
it("carries the originating chat action into order creation", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
).start();
actor.send({
type: "PaymentInit",
planId: "vip_monthly",
chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
});
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
expect(createOrderSpy).toHaveBeenCalledWith({
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
});
actor.stop();
});
it("loads a stable Tip message after payment and clears it on reset", async () => {
const loadTipMessage = vi.fn();
const actor = createActor(
@@ -16,6 +16,7 @@ export const createPaymentOrderActor = fromPromise<
autoRenew: boolean;
recipientCharacterId?: string;
commercialOfferId?: string;
chatActionId?: string;
}
>(async ({ input }) => {
const result = await getPaymentRepository().createOrder(
@@ -24,6 +25,7 @@ export const createPaymentOrderActor = fromPromise<
input.autoRenew,
input.recipientCharacterId,
input.commercialOfferId,
input.chatActionId,
);
if (Result.isErr(result)) throw result.error;
return result.data;
+4 -1
View File
@@ -38,17 +38,20 @@ function initializeCatalogState(
const commercialOfferId =
planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
const offerChanged = commercialOfferId !== context.commercialOfferId;
const chatActionId = event.chatActionId ?? null;
const chatActionChanged = chatActionId !== context.chatActionId;
return {
planCatalog,
...(event.payChannel ? { payChannel: event.payChannel } : {}),
giftCharacterId,
commercialOfferId,
chatActionId,
requestedGiftCategory:
planCatalog === "tip" ? (event.category ?? null) : null,
requestedGiftPlanId:
planCatalog === "tip" ? (event.planId ?? null) : null,
...(catalogChanged || characterChanged || selectionChanged || offerChanged
...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged
? {
...resetOrderState(),
plans: [],
+3
View File
@@ -198,6 +198,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
...(context.commercialOfferId
? { commercialOfferId: context.commercialOfferId }
: {}),
...(context.chatActionId
? { chatActionId: context.chatActionId }
: {}),
}),
onDone: {
target: "pollingOrder",
+1
View File
@@ -11,6 +11,7 @@ export type PaymentEvent =
category?: string | null;
planId?: string | null;
commercialOfferId?: string | null;
chatActionId?: string | null;
}
| { type: "PaymentPlanSelected"; planId: string }
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
+2
View File
@@ -22,6 +22,7 @@ export interface PaymentState {
requestedGiftPlanId: string | null;
isFirstRecharge: boolean;
commercialOfferId: string | null;
chatActionId: string | null;
commercialOffer: CommercialOfferSummary | null;
selectedPlanId: string;
payChannel: PayChannel;
@@ -49,6 +50,7 @@ export const initialState: PaymentState = {
requestedGiftPlanId: null,
isFirstRecharge: false,
commercialOfferId: null,
chatActionId: null,
commercialOffer: null,
selectedPlanId: "",
payChannel: "stripe",
+10 -3
View File
@@ -1,7 +1,10 @@
import { fromPromise } from "xstate";
import { getUserRepository } from "@/data/repositories/user_repository";
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
import type {
UserEntitlementsData,
UserEntitlementSnapshotData,
} from "@/data/schemas/user";
import { UserStorage } from "@/data/storage/user/user_storage";
import type { UserView } from "@/stores/user/user-view";
import { Result } from "@/utils/result";
@@ -15,6 +18,7 @@ import {
export interface UserFetchData {
user: UserView | null;
snapshot: UserEntitlementSnapshotData | null;
entitlements?: UserEntitlementsData | null;
}
export const userFetchActor = fromPromise<UserFetchData>(async () => {
@@ -31,14 +35,17 @@ export const userFetchActor = fromPromise<UserFetchData>(async () => {
creditBalance: entitlementsResult.data.creditBalance,
})
: null;
const entitlements = Result.isOk(entitlementsResult)
? entitlementsResult.data
: null;
if (snapshot) await userStorage.setEntitlementSnapshot(snapshot);
if (Result.isErr(userResult)) return { user: null, snapshot };
if (Result.isErr(userResult)) return { user: null, snapshot, entitlements };
const view = applyEntitlementSnapshotToView(
toView(userResult.data),
snapshot,
);
await userStorage.setUser(userResult.data);
return { user: view, snapshot };
return { user: view, snapshot, entitlements };
});
+1
View File
@@ -49,6 +49,7 @@ const applyFetchedUserAction = userFetchDoneSetup.assign(
context.currentUser,
isVip: snapshot?.isVip ?? context.isVip,
creditBalance: snapshot?.creditBalance ?? context.creditBalance,
entitlements: event.output.entitlements ?? context.entitlements,
isLoading: false,
};
},
+1
View File
@@ -10,6 +10,7 @@ const clearUserAction = profileMachineSetup.assign(() => ({
avatarUrl: null,
isVip: false,
creditBalance: 0,
entitlements: null,
}));
export const userMachineSetup = profileMachineSetup.extend({
+2
View File
@@ -18,6 +18,7 @@ interface UserState {
avatarUrl: string | null;
isVip: boolean;
creditBalance: number;
entitlements: MachineContext["entitlements"];
}
type UserSnapshot = SnapshotFrom<typeof userMachine>;
@@ -59,5 +60,6 @@ function selectUserState(state: UserSnapshot): UserState {
avatarUrl: state.context.avatarUrl,
isVip: state.context.isVip,
creditBalance: state.context.creditBalance,
entitlements: state.context.entitlements,
};
}
+3
View File
@@ -2,6 +2,7 @@
* User 状态机:State 形状 + 初始值
*/
import type { UserView } from "@/stores/user/user-view";
import type { UserEntitlementsData } from "@/data/schemas/user";
export interface UserState {
currentUser: UserView | null;
@@ -9,6 +10,7 @@ export interface UserState {
avatarUrl: string | null;
isVip: boolean;
creditBalance: number;
entitlements: UserEntitlementsData | null;
}
export const initialState: UserState = {
@@ -17,4 +19,5 @@ export const initialState: UserState = {
avatarUrl: null,
isVip: false,
creditBalance: 0,
entitlements: null,
};