feat(payment): implement first recharge offer functionality and UI updates
This commit is contained in:
@@ -145,6 +145,52 @@ describe("paymentMachine", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps first recharge offer metadata from the plans response", async () => {
|
||||
const actor = createActor(
|
||||
paymentMachine.provide({
|
||||
actors: {
|
||||
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
|
||||
async () => null,
|
||||
),
|
||||
refreshPlans: fromPromise(async () =>
|
||||
PaymentPlansResponse.from({
|
||||
isFirstRecharge: true,
|
||||
firstRechargeOffer: {
|
||||
enabled: true,
|
||||
type: "first_recharge_half_price",
|
||||
discountPercent: 50,
|
||||
},
|
||||
plans: [
|
||||
{
|
||||
...monthlyPlan,
|
||||
amountCents: 995,
|
||||
originalAmountCents: 1990,
|
||||
isFirstRechargeOffer: true,
|
||||
firstRechargeDiscountPercent: 50,
|
||||
promotionType: "first_recharge_half_price",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PaymentInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.isFirstRecharge).toBe(true);
|
||||
expect(context.firstRechargeOffer?.discountPercent).toBe(50);
|
||||
expect(context.plans[0]).toMatchObject({
|
||||
amountCents: 995,
|
||||
originalAmountCents: 1990,
|
||||
isFirstRechargeOffer: true,
|
||||
});
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("hydrates cached plans before refreshing with network plans", async () => {
|
||||
let resolveRefresh!: (value: PaymentPlansResponse) => void;
|
||||
const refreshPlansPromise = new Promise<PaymentPlansResponse>((resolve) => {
|
||||
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
export interface PaymentContextState {
|
||||
status: string;
|
||||
plans: MachineContext["plans"];
|
||||
isFirstRecharge: MachineContext["isFirstRecharge"];
|
||||
firstRechargeOffer: MachineContext["firstRechargeOffer"];
|
||||
selectedPlanId: string;
|
||||
payChannel: MachineContext["payChannel"];
|
||||
autoRenew: boolean;
|
||||
@@ -50,6 +52,8 @@ export function PaymentProvider({ children }: PaymentProviderProps) {
|
||||
() => ({
|
||||
status: String(state.value),
|
||||
plans: state.context.plans,
|
||||
isFirstRecharge: state.context.isFirstRecharge,
|
||||
firstRechargeOffer: state.context.firstRechargeOffer,
|
||||
selectedPlanId: state.context.selectedPlanId,
|
||||
payChannel: state.context.payChannel,
|
||||
autoRenew: state.context.autoRenew,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PaymentPlan } from "@/data/dto/payment";
|
||||
import type { PaymentPlan, PaymentPlansResponse } from "@/data/dto/payment";
|
||||
|
||||
import type { PaymentState } from "./payment-state";
|
||||
|
||||
@@ -44,7 +44,10 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string {
|
||||
export function hydratePlansState(
|
||||
plans: PaymentPlan[],
|
||||
selectedPlanId: string,
|
||||
): Pick<PaymentState, "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"> {
|
||||
): Pick<
|
||||
PaymentState,
|
||||
"plans" | "selectedPlanId" | "autoRenew" | "errorMessage"
|
||||
> {
|
||||
const nextSelectedPlanId = selectedPlanId || getDefaultPlanId(plans);
|
||||
return {
|
||||
plans,
|
||||
@@ -54,10 +57,32 @@ export function hydratePlansState(
|
||||
};
|
||||
}
|
||||
|
||||
export function hydratePlansResponseState(
|
||||
response: PaymentPlansResponse,
|
||||
selectedPlanId: string,
|
||||
): Pick<
|
||||
PaymentState,
|
||||
| "plans"
|
||||
| "isFirstRecharge"
|
||||
| "firstRechargeOffer"
|
||||
| "selectedPlanId"
|
||||
| "autoRenew"
|
||||
| "errorMessage"
|
||||
> {
|
||||
return {
|
||||
...hydratePlansState(response.plans, selectedPlanId),
|
||||
isFirstRecharge: response.isFirstRecharge,
|
||||
firstRechargeOffer: response.firstRechargeOffer,
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshPlansState(
|
||||
plans: PaymentPlan[],
|
||||
selectedPlanId: string,
|
||||
): Pick<PaymentState, "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"> {
|
||||
): Pick<
|
||||
PaymentState,
|
||||
"plans" | "selectedPlanId" | "autoRenew" | "errorMessage"
|
||||
> {
|
||||
const nextSelectedPlanId = plans.some((plan) => plan.planId === selectedPlanId)
|
||||
? selectedPlanId
|
||||
: getDefaultPlanId(plans);
|
||||
@@ -69,6 +94,25 @@ export function refreshPlansState(
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshPlansResponseState(
|
||||
response: PaymentPlansResponse,
|
||||
selectedPlanId: string,
|
||||
): Pick<
|
||||
PaymentState,
|
||||
| "plans"
|
||||
| "isFirstRecharge"
|
||||
| "firstRechargeOffer"
|
||||
| "selectedPlanId"
|
||||
| "autoRenew"
|
||||
| "errorMessage"
|
||||
> {
|
||||
return {
|
||||
...refreshPlansState(response.plans, selectedPlanId),
|
||||
isFirstRecharge: response.isFirstRecharge,
|
||||
firstRechargeOffer: response.firstRechargeOffer,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectPlanState(
|
||||
planId: string,
|
||||
plans: readonly PaymentPlan[],
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "./payment-machine.actors";
|
||||
import {
|
||||
hasOrderPollingTimedOut,
|
||||
hydratePlansState,
|
||||
hydratePlansResponseState,
|
||||
PAYMENT_TIMEOUT_ERROR_MESSAGE,
|
||||
refreshPlansState,
|
||||
refreshPlansResponseState,
|
||||
resetOrderState,
|
||||
selectPlanState,
|
||||
toPaymentErrorMessage,
|
||||
@@ -63,8 +63,11 @@ export const paymentMachine = setup({
|
||||
guard: ({ event }) => (event.output?.plans.length ?? 0) > 0,
|
||||
target: "refreshingPlans",
|
||||
actions: assign(({ context, event }) => {
|
||||
const plans = event.output?.plans ?? [];
|
||||
return hydratePlansState(plans, context.selectedPlanId);
|
||||
if (!event.output) return {};
|
||||
return hydratePlansResponseState(
|
||||
event.output,
|
||||
context.selectedPlanId,
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -83,8 +86,10 @@ export const paymentMachine = setup({
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => {
|
||||
const plans = event.output.plans;
|
||||
return hydratePlansState(plans, context.selectedPlanId);
|
||||
return hydratePlansResponseState(
|
||||
event.output,
|
||||
context.selectedPlanId,
|
||||
);
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
@@ -102,8 +107,10 @@ export const paymentMachine = setup({
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => {
|
||||
const plans = event.output.plans;
|
||||
return refreshPlansState(plans, context.selectedPlanId);
|
||||
return refreshPlansResponseState(
|
||||
event.output,
|
||||
context.selectedPlanId,
|
||||
);
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Payment 状态机:State 形状 + 初始值
|
||||
*/
|
||||
import type {
|
||||
FirstRechargeOffer,
|
||||
PayChannel,
|
||||
PaymentOrderStatus,
|
||||
PaymentPlan,
|
||||
@@ -9,6 +10,8 @@ import type {
|
||||
|
||||
export interface PaymentState {
|
||||
plans: PaymentPlan[];
|
||||
isFirstRecharge: boolean;
|
||||
firstRechargeOffer: FirstRechargeOffer | null;
|
||||
selectedPlanId: string;
|
||||
payChannel: PayChannel;
|
||||
autoRenew: boolean;
|
||||
@@ -23,6 +26,8 @@ export interface PaymentState {
|
||||
|
||||
export const initialState: PaymentState = {
|
||||
plans: [],
|
||||
isFirstRecharge: false,
|
||||
firstRechargeOffer: null,
|
||||
selectedPlanId: "",
|
||||
payChannel: "stripe",
|
||||
autoRenew: true,
|
||||
|
||||
Reference in New Issue
Block a user