feat(chat): add support action cards and live entitlements
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -198,6 +198,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
|
||||
...(context.commercialOfferId
|
||||
? { commercialOfferId: context.commercialOfferId }
|
||||
: {}),
|
||||
...(context.chatActionId
|
||||
? { chatActionId: context.chatActionId }
|
||||
: {}),
|
||||
}),
|
||||
onDone: {
|
||||
target: "pollingOrder",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ const clearUserAction = profileMachineSetup.assign(() => ({
|
||||
avatarUrl: null,
|
||||
isVip: false,
|
||||
creditBalance: 0,
|
||||
entitlements: null,
|
||||
}));
|
||||
|
||||
export const userMachineSetup = profileMachineSetup.extend({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user