feat(tip): add personalized payment success experience

This commit is contained in:
2026-07-20 19:06:42 +08:00
parent c187f0b817
commit edf50e9cc4
16 changed files with 922 additions and 74 deletions
@@ -101,6 +101,10 @@ export function createTestPaymentMachine(
createOrderError: Error;
orderStatus: "pending" | "paid" | "failed";
orderStatuses: ("pending" | "paid" | "failed")[];
orderType: string;
orderPlanId: string;
tipCount: number | null;
thankYouMessage: string | null;
}> = {},
) {
const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>();
@@ -150,8 +154,10 @@ export function createTestPaymentMachine(
return PaymentOrderStatusResponseSchema.parse({
orderId: "pay_test_001",
status,
orderType: "vip_monthly",
planId: "vip_monthly",
orderType: overrides.orderType ?? "vip_monthly",
planId: overrides.orderPlanId ?? "vip_monthly",
tipCount: overrides.tipCount ?? null,
thankYouMessage: overrides.thankYouMessage ?? null,
});
}),
},
@@ -65,6 +65,8 @@ describe("payment order flow", () => {
expect(actor.getSnapshot().context).toMatchObject({
currentOrderId: "pay_test_001",
orderStatus: "paid",
tipCount: null,
thankYouMessage: null,
orderPollingStartedAt: null,
launchNonce: 1,
});
@@ -79,6 +81,80 @@ describe("payment order flow", () => {
actor.stop();
});
it("stores a stable Tip success result and clears it on reset", async () => {
const actor = createActor(
createTestPaymentMachine({
orderType: "tip",
orderPlanId: "tip_coffee_usd_9_99",
tipCount: 2,
thankYouMessage: "You made my day.",
}),
).start();
await initialize(actor);
actor.send({
type: "PaymentCreateOrderSubmitted",
recipientCharacterId: "maya-tan",
});
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
expect(actor.getSnapshot().context).toMatchObject({
orderStatus: "paid",
tipCount: 2,
thankYouMessage: "You made my day.",
});
actor.send({ type: "PaymentReset" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context).toMatchObject({
tipCount: null,
thankYouMessage: null,
});
actor.stop();
});
it("clears the previous Tip result before creating another order", async () => {
const actor = createActor(
createTestPaymentMachine({
orderType: "tip",
tipCount: 3,
thankYouMessage: "Another warm coffee.",
}),
).start();
await initialize(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
expect(actor.getSnapshot().matches("creatingOrder")).toBe(true);
expect(actor.getSnapshot().context).toMatchObject({
tipCount: null,
thankYouMessage: null,
});
actor.stop();
});
it("clears the previous Tip result when the payment channel changes", async () => {
const actor = createActor(
createTestPaymentMachine({
orderType: "tip",
tipCount: 4,
thankYouMessage: "That was so thoughtful.",
}),
).start();
await initialize(actor);
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
actor.send({ type: "PaymentPayChannelChanged", payChannel: "ezpay" });
expect(actor.getSnapshot().matches("ready")).toBe(true);
expect(actor.getSnapshot().context).toMatchObject({
payChannel: "ezpay",
tipCount: null,
thankYouMessage: null,
});
actor.stop();
});
it("passes the selected tip recipient to order creation", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
@@ -135,6 +211,8 @@ describe("payment order flow", () => {
currentOrderId: null,
payParams: null,
orderStatus: null,
tipCount: null,
thankYouMessage: null,
orderPollingStartedAt: null,
errorMessage: null,
});
+2
View File
@@ -25,6 +25,8 @@ export function resetOrderState(): Partial<PaymentState> {
currentOrderPlanId: null,
payParams: null,
orderStatus: null,
tipCount: null,
thankYouMessage: null,
orderPollingStartedAt: null,
errorMessage: null,
};
+6
View File
@@ -47,6 +47,8 @@ const applyReturnedOrderAction = catalogMachineSetup.assign(({ event }) => {
currentOrderPlanId: null,
payParams: null,
orderStatus: "pending",
tipCount: null,
thankYouMessage: null,
orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null,
};
@@ -97,6 +99,8 @@ const applyCreateOrderSuccessAction = createOrderDoneSetup.assign(
currentOrderPlanId: context.selectedPlanId,
payParams: event.output.payParams,
orderStatus: "pending",
tipCount: null,
thankYouMessage: null,
orderPollingStartedAt: Date.now(),
errorMessage: null,
launchNonce: context.launchNonce + 1,
@@ -134,6 +138,8 @@ const trackPollTimeoutAction = pollOrderDoneSetup.createAction(
const applyPaidOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
orderStatus: event.output.status,
tipCount: event.output.tipCount,
thankYouMessage: event.output.thankYouMessage,
orderPollingStartedAt: null,
errorMessage: null,
}));
+4
View File
@@ -24,6 +24,8 @@ export interface PaymentContextState {
currentOrderId: string | null;
payParams: Record<string, unknown> | null;
orderStatus: MachineContext["orderStatus"];
tipCount: number | null;
thankYouMessage: string | null;
errorMessage: string | null;
launchNonce: number;
isLoadingPlans: boolean;
@@ -72,6 +74,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
currentOrderId: state.context.currentOrderId,
payParams: state.context.payParams,
orderStatus: state.context.orderStatus,
tipCount: state.context.tipCount,
thankYouMessage: state.context.thankYouMessage,
errorMessage: state.context.errorMessage,
launchNonce: state.context.launchNonce,
isLoadingPlans:
+4
View File
@@ -21,6 +21,8 @@ export interface PaymentState {
currentOrderPlanId: string | null;
payParams: Record<string, unknown> | null;
orderStatus: PaymentOrderStatus | null;
tipCount: number | null;
thankYouMessage: string | null;
orderPollingStartedAt: number | null;
errorMessage: string | null;
launchNonce: number;
@@ -38,6 +40,8 @@ export const initialState: PaymentState = {
currentOrderPlanId: null,
payParams: null,
orderStatus: null,
tipCount: null,
thankYouMessage: null,
orderPollingStartedAt: null,
errorMessage: null,
launchNonce: 0,