feat(tip): support dynamic gift products
This commit is contained in:
@@ -8,10 +8,10 @@ import {
|
||||
|
||||
import {
|
||||
createTestPaymentMachine,
|
||||
giftCatalog,
|
||||
lifetimePlan,
|
||||
monthlyPlan,
|
||||
quarterlyPlan,
|
||||
tipPlan,
|
||||
} from "./payment-machine.test-utils";
|
||||
|
||||
describe("payment catalog flow", () => {
|
||||
@@ -31,25 +31,76 @@ describe("payment catalog flow", () => {
|
||||
});
|
||||
|
||||
it("loads the tip catalog and disables auto renew", async () => {
|
||||
const loadCatalog = vi.fn();
|
||||
const refreshCatalog = vi.fn();
|
||||
const loadGiftProducts = vi.fn();
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
refreshedPlans: PaymentPlansResponseSchema.parse({ plans: [tipPlan] }),
|
||||
onLoadCatalog: loadCatalog,
|
||||
onRefreshCatalog: refreshCatalog,
|
||||
onLoadGiftProducts: loadGiftProducts,
|
||||
}),
|
||||
).start();
|
||||
actor.send({ type: "PaymentInit", catalog: "tip" });
|
||||
actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
expect(loadCatalog).toHaveBeenCalledWith("tip");
|
||||
expect(refreshCatalog).toHaveBeenCalledWith("tip");
|
||||
expect(loadGiftProducts).toHaveBeenCalledWith("elio");
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
planCatalog: "tip",
|
||||
selectedPlanId: tipPlan.planId,
|
||||
selectedGiftCategory: "coffee",
|
||||
selectedPlanId: "tip_coffee_usd_4_99",
|
||||
autoRenew: false,
|
||||
});
|
||||
expect(actor.getSnapshot().context.plans).toHaveLength(2);
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("restores a valid first-category product and rejects another category", async () => {
|
||||
const actor = createActor(createTestPaymentMachine()).start();
|
||||
actor.send({
|
||||
type: "PaymentInit",
|
||||
catalog: "tip",
|
||||
characterId: "elio",
|
||||
category: "coffee",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
});
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
||||
"tip_coffee_usd_9_99",
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "PaymentInit",
|
||||
catalog: "tip",
|
||||
characterId: "elio",
|
||||
category: "flowers",
|
||||
planId: "tip_flowers_usd_12_99",
|
||||
});
|
||||
await waitFor(
|
||||
actor,
|
||||
(snapshot) =>
|
||||
snapshot.matches("ready") &&
|
||||
snapshot.context.requestedGiftPlanId === null,
|
||||
);
|
||||
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
||||
giftCatalog.plans[0]?.planId,
|
||||
);
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears gift prices after a catalog request fails and supports retry", async () => {
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
giftProductsError: new Error("catalog unavailable"),
|
||||
}),
|
||||
).start();
|
||||
actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
plans: [],
|
||||
giftProducts: [],
|
||||
selectedPlanId: "",
|
||||
errorMessage: "catalog unavailable",
|
||||
});
|
||||
actor.send({ type: "PaymentCatalogRetryRequested" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("loadingGiftProducts"));
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@ import { fromPromise } from "xstate";
|
||||
import {
|
||||
CreatePaymentOrderResponse,
|
||||
CreatePaymentOrderResponseSchema,
|
||||
GiftProductsResponse,
|
||||
GiftProductsResponseSchema,
|
||||
type PayChannel,
|
||||
type PaymentOrderStatus,
|
||||
PaymentOrderStatusResponseSchema,
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansResponseSchema,
|
||||
TipMessageResponse,
|
||||
TipMessageResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
import { paymentMachine } from "@/stores/payment/payment-machine";
|
||||
import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
|
||||
@@ -77,9 +82,77 @@ export const quarterlyPlan = {
|
||||
currency: "usd",
|
||||
};
|
||||
|
||||
export const giftCatalog = GiftProductsResponseSchema.parse({
|
||||
characterId: "elio",
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 2,
|
||||
imageUrl: null,
|
||||
},
|
||||
{
|
||||
category: "flowers",
|
||||
name: "Flowers",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Velvet Espresso",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_small",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "A small coffee",
|
||||
imageUrl: null,
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
{
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Golden Reserve",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_medium",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "A medium coffee",
|
||||
imageUrl: null,
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
{
|
||||
planId: "tip_flowers_usd_12_99",
|
||||
planName: "Rose Bouquet",
|
||||
orderType: "tip",
|
||||
tipType: "flowers",
|
||||
category: "flowers",
|
||||
characterId: "elio",
|
||||
description: "A bouquet",
|
||||
imageUrl: null,
|
||||
amountCents: 1299,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const tipPlan = {
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
planName: "Velvet Espresso",
|
||||
orderType: "tip",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
@@ -90,21 +163,35 @@ export const tipPlan = {
|
||||
currency: "USD",
|
||||
};
|
||||
|
||||
const defaultTipMessage = TipMessageResponseSchema.parse({
|
||||
orderId: "pay_test_001",
|
||||
characterId: "elio",
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
productName: "Velvet Espresso",
|
||||
tipCount: 1,
|
||||
poolIndex: 37,
|
||||
message: "Thank you for the thoughtful gift.",
|
||||
});
|
||||
|
||||
export function createTestPaymentMachine(
|
||||
overrides: Partial<{
|
||||
cachedPlans: PaymentPlansResponse | null;
|
||||
refreshedPlans: PaymentPlansResponse;
|
||||
refreshPlans: () => Promise<PaymentPlansResponse>;
|
||||
giftProducts: GiftProductsResponse;
|
||||
giftProductsError: Error;
|
||||
onLoadCatalog: (catalog: PaymentPlanCatalog) => void;
|
||||
onRefreshCatalog: (catalog: PaymentPlanCatalog) => void;
|
||||
onLoadGiftProducts: (characterId: string) => void;
|
||||
createOrderSpy: CreateOrderSpy;
|
||||
createOrderError: Error;
|
||||
orderStatus: "pending" | "paid" | "failed";
|
||||
orderStatuses: ("pending" | "paid" | "failed")[];
|
||||
orderStatus: PaymentOrderStatus;
|
||||
orderStatuses: PaymentOrderStatus[];
|
||||
orderType: string;
|
||||
orderPlanId: string;
|
||||
tipCount: number | null;
|
||||
thankYouMessage: string | null;
|
||||
orderPlanId: string | null;
|
||||
tipMessage: TipMessageResponse;
|
||||
tipMessageError: Error;
|
||||
onLoadTipMessage: (orderId: string) => void;
|
||||
}> = {},
|
||||
) {
|
||||
const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>();
|
||||
@@ -133,6 +220,14 @@ export function createTestPaymentMachine(
|
||||
);
|
||||
},
|
||||
),
|
||||
loadGiftProducts: fromPromise<
|
||||
GiftProductsResponse,
|
||||
{ characterId: string }
|
||||
>(async ({ input }) => {
|
||||
overrides.onLoadGiftProducts?.(input.characterId);
|
||||
if (overrides.giftProductsError) throw overrides.giftProductsError;
|
||||
return overrides.giftProducts ?? giftCatalog;
|
||||
}),
|
||||
createOrder: fromPromise<CreatePaymentOrderResponse, CreateOrderInput>(
|
||||
async ({ input }) => {
|
||||
createOrderSpy(input);
|
||||
@@ -156,10 +251,16 @@ export function createTestPaymentMachine(
|
||||
status,
|
||||
orderType: overrides.orderType ?? "vip_monthly",
|
||||
planId: overrides.orderPlanId ?? "vip_monthly",
|
||||
tipCount: overrides.tipCount ?? null,
|
||||
thankYouMessage: overrides.thankYouMessage ?? null,
|
||||
creditsAdded: 0,
|
||||
});
|
||||
}),
|
||||
loadTipMessage: fromPromise<TipMessageResponse, { orderId: string }>(
|
||||
async ({ input }) => {
|
||||
overrides.onLoadTipMessage?.(input.orderId);
|
||||
if (overrides.tipMessageError) throw overrides.tipMessageError;
|
||||
return overrides.tipMessage ?? defaultTipMessage;
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ describe("payment order flow", () => {
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
currentOrderId: "pay_test_001",
|
||||
orderStatus: "paid",
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
orderPollingStartedAt: null,
|
||||
launchNonce: 1,
|
||||
});
|
||||
@@ -81,33 +81,46 @@ describe("payment order flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("stores a stable Tip success result and clears it on reset", async () => {
|
||||
it("loads a stable Tip message after payment and clears it on reset", async () => {
|
||||
const loadTipMessage = vi.fn();
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
orderType: "tip",
|
||||
orderPlanId: "tip_coffee_usd_9_99",
|
||||
tipCount: 2,
|
||||
thankYouMessage: "You made my day.",
|
||||
onLoadTipMessage: loadTipMessage,
|
||||
tipMessage: {
|
||||
orderId: "pay_test_001",
|
||||
characterId: "elio",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
productName: "Golden Reserve",
|
||||
tipCount: 2,
|
||||
poolIndex: 18,
|
||||
message: "You made my day.",
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
await initialize(actor);
|
||||
await initializeTip(actor);
|
||||
actor.send({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
recipientCharacterId: "maya-tan",
|
||||
recipientCharacterId: "elio",
|
||||
});
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
|
||||
expect(loadTipMessage).toHaveBeenCalledWith("pay_test_001");
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
orderStatus: "paid",
|
||||
tipCount: 2,
|
||||
thankYouMessage: "You made my day.",
|
||||
tipMessage: expect.objectContaining({
|
||||
tipCount: 2,
|
||||
message: "You made my day.",
|
||||
}),
|
||||
tipMessageError: null,
|
||||
});
|
||||
|
||||
actor.send({ type: "PaymentReset" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
@@ -116,19 +129,18 @@ describe("payment order flow", () => {
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
orderType: "tip",
|
||||
tipCount: 3,
|
||||
thankYouMessage: "Another warm coffee.",
|
||||
orderPlanId: "tip_coffee_usd_4_99",
|
||||
}),
|
||||
).start();
|
||||
await initialize(actor);
|
||||
await initializeTip(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,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
@@ -137,11 +149,10 @@ describe("payment order flow", () => {
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
orderType: "tip",
|
||||
tipCount: 4,
|
||||
thankYouMessage: "That was so thoughtful.",
|
||||
orderPlanId: "tip_coffee_usd_4_99",
|
||||
}),
|
||||
).start();
|
||||
await initialize(actor);
|
||||
await initializeTip(actor);
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
|
||||
@@ -149,8 +160,8 @@ describe("payment order flow", () => {
|
||||
expect(actor.getSnapshot().matches("ready")).toBe(true);
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
payChannel: "ezpay",
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
@@ -211,8 +222,8 @@ describe("payment order flow", () => {
|
||||
currentOrderId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
@@ -235,6 +246,48 @@ describe("payment order flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("moves expired orders to a dedicated terminal state", async () => {
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({ orderStatus: "expired" }),
|
||||
).start();
|
||||
await initialize(actor);
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("expired"));
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
orderStatus: "expired",
|
||||
payParams: null,
|
||||
errorMessage:
|
||||
"This payment order has expired. Please create a new order.",
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps paid state when the Tip message fails and retries only the message", async () => {
|
||||
const loadTipMessage = vi.fn();
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
orderType: "tip",
|
||||
orderPlanId: "tip_coffee_usd_4_99",
|
||||
tipMessageError: new Error("message unavailable"),
|
||||
onLoadTipMessage: loadTipMessage,
|
||||
}),
|
||||
).start();
|
||||
await initializeTip(actor);
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("tipMessageFailed"));
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
orderStatus: "paid",
|
||||
tipMessage: null,
|
||||
tipMessageError: "message unavailable",
|
||||
});
|
||||
actor.send({ type: "PaymentTipMessageRetryRequested" });
|
||||
await waitFor(actor, () => loadTipMessage.mock.calls.length === 2);
|
||||
expect(actor.getSnapshot().context.orderStatus).toBe("paid");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("times out an old pending order", async () => {
|
||||
const statusPollTimeout = vi
|
||||
.spyOn(behaviorAnalytics, "statusPollTimeout")
|
||||
@@ -336,3 +389,10 @@ async function initialize(
|
||||
actor.send({ type: "PaymentInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
}
|
||||
|
||||
async function initializeTip(
|
||||
actor: ReturnType<typeof createActor<ReturnType<typeof createTestPaymentMachine>>>,
|
||||
): Promise<void> {
|
||||
actor.send({ type: "PaymentInit", catalog: "tip", characterId: "elio" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
PaymentPlanSchema,
|
||||
type GiftProduct,
|
||||
type GiftProductsResponse,
|
||||
type PaymentPlan,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import type { PaymentState } from "../payment-state";
|
||||
|
||||
export function giftProductToPaymentPlan(
|
||||
product: GiftProduct,
|
||||
): PaymentPlan {
|
||||
return PaymentPlanSchema.parse({
|
||||
planId: product.planId,
|
||||
planName: product.planName,
|
||||
orderType: product.orderType,
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
creditBalance: 0,
|
||||
amountCents: product.amountCents,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: product.currency,
|
||||
isFirstRechargeOffer: false,
|
||||
mostPopular: false,
|
||||
firstRechargeDiscountPercent: null,
|
||||
promotionType: product.promotionType,
|
||||
});
|
||||
}
|
||||
|
||||
export function hydrateGiftProductsState(
|
||||
response: GiftProductsResponse,
|
||||
characterId: string,
|
||||
restoredCategory: string | null,
|
||||
restoredPlanId: string | null,
|
||||
): Pick<
|
||||
PaymentState,
|
||||
| "plans"
|
||||
| "giftCategories"
|
||||
| "giftProducts"
|
||||
| "selectedGiftCategory"
|
||||
| "selectedPlanId"
|
||||
| "requestedGiftCategory"
|
||||
| "requestedGiftPlanId"
|
||||
| "autoRenew"
|
||||
| "isFirstRecharge"
|
||||
| "errorMessage"
|
||||
> {
|
||||
const giftCategories = response.categories;
|
||||
const giftProducts = response.plans.filter(
|
||||
(product) => product.characterId === characterId,
|
||||
);
|
||||
const selectedGiftCategory = giftCategories[0]?.category ?? null;
|
||||
const visibleProducts = selectedGiftCategory
|
||||
? giftProducts.filter(
|
||||
(product) => product.category === selectedGiftCategory,
|
||||
)
|
||||
: [];
|
||||
const canRestoreSelection =
|
||||
restoredCategory === selectedGiftCategory &&
|
||||
visibleProducts.some((product) => product.planId === restoredPlanId);
|
||||
const selectedPlanId = canRestoreSelection
|
||||
? (restoredPlanId ?? "")
|
||||
: (visibleProducts[0]?.planId ?? "");
|
||||
|
||||
return {
|
||||
plans: visibleProducts.map(giftProductToPaymentPlan),
|
||||
giftCategories,
|
||||
giftProducts,
|
||||
selectedGiftCategory,
|
||||
selectedPlanId,
|
||||
requestedGiftCategory: null,
|
||||
requestedGiftPlanId: null,
|
||||
autoRenew: false,
|
||||
isFirstRecharge: false,
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSelectedGiftProduct(
|
||||
context: Pick<PaymentState, "giftProducts" | "selectedPlanId">,
|
||||
): GiftProduct | null {
|
||||
return (
|
||||
context.giftProducts.find(
|
||||
(product) => product.planId === context.selectedPlanId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./catalog";
|
||||
export * from "./gift";
|
||||
export * from "./order";
|
||||
|
||||
@@ -25,8 +25,8 @@ export function resetOrderState(): Partial<PaymentState> {
|
||||
currentOrderPlanId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import type {
|
||||
GiftProductsResponse,
|
||||
TipMessageResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
import { getPaymentRepository } from "@/data/repositories/payment_repository";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
export const loadGiftProductsActor = fromPromise<
|
||||
GiftProductsResponse,
|
||||
{ characterId: string }
|
||||
>(async ({ input }) => {
|
||||
if (!input.characterId) throw new Error("Missing gift recipient character.");
|
||||
const result = await getPaymentRepository().getGiftProducts(
|
||||
input.characterId,
|
||||
);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
|
||||
export const loadTipMessageActor = fromPromise<
|
||||
TipMessageResponse,
|
||||
{ orderId: string }
|
||||
>(async ({ input }) => {
|
||||
if (!input.orderId) throw new Error("Missing paid Tip order id.");
|
||||
const result = await getPaymentRepository().getTipMessage(input.orderId);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
@@ -21,10 +21,10 @@ export const refreshPaymentPlansActor = fromPromise<
|
||||
{ catalog: PaymentPlanCatalog }
|
||||
>(async ({ input }) => {
|
||||
const paymentRepo = getPaymentRepository();
|
||||
const result =
|
||||
input.catalog === "tip"
|
||||
? await paymentRepo.getTipPlans()
|
||||
: await paymentRepo.getPlans();
|
||||
if (input.catalog === "tip") {
|
||||
throw new Error("Gift catalogs must use the gift products actor.");
|
||||
}
|
||||
const result = await paymentRepo.getPlans();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
|
||||
@@ -1,50 +1,76 @@
|
||||
import type { DoneActorEvent, ErrorActorEvent } from "xstate";
|
||||
|
||||
import type { PaymentPlansResponse } from "@/data/schemas/payment";
|
||||
import type {
|
||||
GiftProductsResponse,
|
||||
PaymentPlansResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import {
|
||||
consumeFirstRechargeState,
|
||||
hydrateGiftProductsState,
|
||||
hydratePlansResponseState,
|
||||
refreshPlansResponseState,
|
||||
selectPlanState,
|
||||
} from "../helper/catalog";
|
||||
import {
|
||||
resetOrderState,
|
||||
toPaymentErrorMessage,
|
||||
} from "../helper/order";
|
||||
} from "../helper";
|
||||
import { resetOrderState, toPaymentErrorMessage } from "../helper/order";
|
||||
import type { PaymentEvent } from "../payment-events";
|
||||
import type { PaymentState } from "../payment-state";
|
||||
import {
|
||||
basePaymentMachineSetup,
|
||||
createPaymentActorActionSetup,
|
||||
} from "./setup";
|
||||
|
||||
function initializeCatalogState(
|
||||
context: PaymentState,
|
||||
event: Extract<PaymentEvent, { type: "PaymentInit" }>,
|
||||
): Partial<PaymentState> {
|
||||
const planCatalog = event.catalog ?? context.planCatalog;
|
||||
const giftCharacterId =
|
||||
planCatalog === "tip"
|
||||
? (event.characterId ?? context.giftCharacterId)
|
||||
: null;
|
||||
const catalogChanged = planCatalog !== context.planCatalog;
|
||||
const characterChanged = giftCharacterId !== context.giftCharacterId;
|
||||
const selectionChanged =
|
||||
planCatalog === "tip" &&
|
||||
((event.category ?? null) !== context.selectedGiftCategory ||
|
||||
(event.planId ?? null) !== (context.selectedPlanId || null));
|
||||
|
||||
return {
|
||||
planCatalog,
|
||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
||||
giftCharacterId,
|
||||
requestedGiftCategory:
|
||||
planCatalog === "tip" ? (event.category ?? null) : null,
|
||||
requestedGiftPlanId:
|
||||
planCatalog === "tip" ? (event.planId ?? null) : null,
|
||||
...(catalogChanged || characterChanged || selectionChanged
|
||||
? {
|
||||
...resetOrderState(),
|
||||
plans: [],
|
||||
giftCategories: [],
|
||||
giftProducts: [],
|
||||
selectedGiftCategory: null,
|
||||
selectedPlanId: "",
|
||||
isFirstRecharge: false,
|
||||
autoRenew: planCatalog !== "tip",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const initializeCatalogAction = basePaymentMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "PaymentInit") return {};
|
||||
return {
|
||||
planCatalog: event.catalog ?? context.planCatalog,
|
||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
||||
};
|
||||
},
|
||||
({ context, event }) =>
|
||||
event.type === "PaymentInit"
|
||||
? initializeCatalogState(context, event)
|
||||
: {},
|
||||
);
|
||||
|
||||
const refreshCatalogAction = basePaymentMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "PaymentInit") return {};
|
||||
const planCatalog = event.catalog ?? context.planCatalog;
|
||||
const catalogChanged = planCatalog !== context.planCatalog;
|
||||
return {
|
||||
planCatalog,
|
||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
||||
...(catalogChanged
|
||||
? {
|
||||
plans: [],
|
||||
selectedPlanId: "",
|
||||
isFirstRecharge: false,
|
||||
autoRenew: planCatalog !== "tip",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
({ context, event }) =>
|
||||
event.type === "PaymentInit"
|
||||
? initializeCatalogState(context, event)
|
||||
: {},
|
||||
);
|
||||
|
||||
const selectPlanAction = basePaymentMachineSetup.assign(
|
||||
@@ -105,6 +131,12 @@ export const catalogMachineSetup = basePaymentMachineSetup.extend({
|
||||
resetOrder: resetOrderAction,
|
||||
consumeFirstRecharge: consumeFirstRechargeAction,
|
||||
},
|
||||
guards: {
|
||||
isTipInit: ({ context, event }) =>
|
||||
event.type === "PaymentInit" &&
|
||||
(event.catalog ?? context.planCatalog) === "tip",
|
||||
isTipCatalog: ({ context }) => context.planCatalog === "tip",
|
||||
},
|
||||
});
|
||||
|
||||
const cachedPlansDoneSetup =
|
||||
@@ -113,6 +145,8 @@ const cachedPlansDoneSetup =
|
||||
>();
|
||||
const plansDoneSetup =
|
||||
createPaymentActorActionSetup<DoneActorEvent<PaymentPlansResponse>>();
|
||||
const giftProductsDoneSetup =
|
||||
createPaymentActorActionSetup<DoneActorEvent<GiftProductsResponse>>();
|
||||
const plansErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>();
|
||||
|
||||
const hydrateCachedPlansAction = cachedPlansDoneSetup.assign(
|
||||
@@ -130,16 +164,42 @@ const refreshPlansAction = plansDoneSetup.assign(({ context, event }) =>
|
||||
refreshPlansResponseState(event.output, context.selectedPlanId),
|
||||
);
|
||||
|
||||
const hydrateGiftProductsAction = giftProductsDoneSetup.assign(
|
||||
({ context, event }) =>
|
||||
hydrateGiftProductsState(
|
||||
event.output,
|
||||
context.giftCharacterId ?? "",
|
||||
context.requestedGiftCategory ?? context.selectedGiftCategory,
|
||||
context.requestedGiftPlanId ?? context.selectedPlanId,
|
||||
),
|
||||
);
|
||||
|
||||
const applyPlansErrorAction = plansErrorSetup.assign(({ event }) => ({
|
||||
errorMessage: toPaymentErrorMessage(event.error),
|
||||
}));
|
||||
|
||||
const applyGiftProductsErrorAction = plansErrorSetup.assign(({ event }) => ({
|
||||
plans: [],
|
||||
giftCategories: [],
|
||||
giftProducts: [],
|
||||
selectedGiftCategory: null,
|
||||
selectedPlanId: "",
|
||||
errorMessage: toPaymentErrorMessage(event.error),
|
||||
}));
|
||||
|
||||
export const idleState = catalogMachineSetup.createStateConfig({
|
||||
on: {
|
||||
PaymentInit: {
|
||||
target: "loadingCachedPlans",
|
||||
actions: "initializeCatalog",
|
||||
},
|
||||
PaymentInit: [
|
||||
{
|
||||
guard: "isTipInit",
|
||||
target: "loadingGiftProducts",
|
||||
actions: "initializeCatalog",
|
||||
},
|
||||
{
|
||||
target: "loadingCachedPlans",
|
||||
actions: "initializeCatalog",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -190,12 +250,44 @@ export const refreshingPlansState = catalogMachineSetup.createStateConfig({
|
||||
},
|
||||
});
|
||||
|
||||
export const loadingGiftProductsState =
|
||||
catalogMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "loadGiftProducts",
|
||||
input: ({ context }) => ({
|
||||
characterId: context.giftCharacterId ?? "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: hydrateGiftProductsAction,
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: applyGiftProductsErrorAction,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const readyState = catalogMachineSetup.createStateConfig({
|
||||
on: {
|
||||
PaymentInit: {
|
||||
target: "refreshingPlans",
|
||||
actions: "refreshCatalog",
|
||||
},
|
||||
PaymentInit: [
|
||||
{
|
||||
guard: "isTipInit",
|
||||
target: "loadingGiftProducts",
|
||||
actions: "refreshCatalog",
|
||||
},
|
||||
{
|
||||
target: "refreshingPlans",
|
||||
actions: "refreshCatalog",
|
||||
},
|
||||
],
|
||||
PaymentCatalogRetryRequested: [
|
||||
{
|
||||
guard: "isTipCatalog",
|
||||
target: "loadingGiftProducts",
|
||||
},
|
||||
{ target: "refreshingPlans" },
|
||||
],
|
||||
PaymentPlanSelected: { actions: "selectPlan" },
|
||||
PaymentPayChannelChanged: { actions: "changePayChannel" },
|
||||
PaymentAutoRenewChanged: { actions: "changeAutoRenew" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DoneActorEvent, ErrorActorEvent } from "xstate";
|
||||
import type {
|
||||
CreatePaymentOrderResponse,
|
||||
PaymentOrderStatusResponse,
|
||||
TipMessageResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
import { behaviorAnalytics } from "@/lib/analytics";
|
||||
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
idleState,
|
||||
loadingCachedPlansState,
|
||||
loadingPlansState,
|
||||
loadingGiftProductsState,
|
||||
readyState,
|
||||
refreshingPlansState,
|
||||
} from "./catalog-flow";
|
||||
@@ -47,8 +49,8 @@ const applyReturnedOrderAction = catalogMachineSetup.assign(({ event }) => {
|
||||
currentOrderPlanId: null,
|
||||
payParams: null,
|
||||
orderStatus: "pending",
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
orderPollingStartedAt: event.createdAt ?? Date.now(),
|
||||
errorMessage: null,
|
||||
};
|
||||
@@ -77,6 +79,8 @@ const pollOrderDoneSetup =
|
||||
createPaymentActorActionSetup<
|
||||
DoneActorEvent<PaymentOrderStatusResponse>
|
||||
>();
|
||||
const tipMessageDoneSetup =
|
||||
createPaymentActorActionSetup<DoneActorEvent<TipMessageResponse>>();
|
||||
const orderErrorSetup = createPaymentActorActionSetup<ErrorActorEvent>();
|
||||
|
||||
const trackCreateOrderSuccessAction = createOrderDoneSetup.createAction(
|
||||
@@ -99,8 +103,8 @@ const applyCreateOrderSuccessAction = createOrderDoneSetup.assign(
|
||||
currentOrderPlanId: context.selectedPlanId,
|
||||
payParams: event.output.payParams,
|
||||
orderStatus: "pending",
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
orderPollingStartedAt: Date.now(),
|
||||
errorMessage: null,
|
||||
launchNonce: context.launchNonce + 1,
|
||||
@@ -138,8 +142,8 @@ const trackPollTimeoutAction = pollOrderDoneSetup.createAction(
|
||||
|
||||
const applyPaidOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
orderStatus: event.output.status,
|
||||
tipCount: event.output.tipCount,
|
||||
thankYouMessage: event.output.thankYouMessage,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
}));
|
||||
@@ -150,6 +154,13 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
errorMessage: "Payment failed or was cancelled.",
|
||||
}));
|
||||
|
||||
const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
orderStatus: event.output.status,
|
||||
payParams: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: "This payment order has expired. Please create a new order.",
|
||||
}));
|
||||
|
||||
const applyTimedOutOrderAction = pollOrderDoneSetup.assign({
|
||||
orderStatus: "failed",
|
||||
orderPollingStartedAt: null,
|
||||
@@ -161,6 +172,18 @@ const applyPendingOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
errorMessage: null,
|
||||
}));
|
||||
|
||||
const applyTipMessageSuccessAction = tipMessageDoneSetup.assign(
|
||||
({ event }) => ({
|
||||
tipMessage: event.output,
|
||||
tipMessageError: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const applyTipMessageErrorAction = orderErrorSetup.assign(({ event }) => ({
|
||||
tipMessage: null,
|
||||
tipMessageError: toPaymentErrorMessage(event.error),
|
||||
}));
|
||||
|
||||
const creatingOrderState = paymentMachineSetup.createStateConfig({
|
||||
entry: "trackCreateOrderStart",
|
||||
invoke: {
|
||||
@@ -193,11 +216,22 @@ const pollingOrderState = paymentMachineSetup.createStateConfig({
|
||||
src: "pollOrderStatus",
|
||||
input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }),
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ context, event }) =>
|
||||
event.output.status === "paid" && context.planCatalog === "tip",
|
||||
target: "loadingTipMessage",
|
||||
actions: [trackPollUpdateAction, applyPaidOrderAction],
|
||||
},
|
||||
{
|
||||
guard: ({ event }) => event.output.status === "paid",
|
||||
target: "paid",
|
||||
actions: [trackPollUpdateAction, applyPaidOrderAction],
|
||||
},
|
||||
{
|
||||
guard: ({ event }) => event.output.status === "expired",
|
||||
target: "expired",
|
||||
actions: [trackPollUpdateAction, applyExpiredOrderAction],
|
||||
},
|
||||
{
|
||||
guard: ({ event }) => event.output.status === "failed",
|
||||
target: "failed",
|
||||
@@ -239,6 +273,45 @@ const waitingForPaymentState = paymentMachineSetup.createStateConfig({
|
||||
},
|
||||
});
|
||||
|
||||
const loadingTipMessageState = paymentMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "loadTipMessage",
|
||||
input: ({ context }) => ({ orderId: context.currentOrderId ?? "" }),
|
||||
onDone: {
|
||||
target: "paid",
|
||||
actions: applyTipMessageSuccessAction,
|
||||
},
|
||||
onError: {
|
||||
target: "tipMessageFailed",
|
||||
actions: applyTipMessageErrorAction,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
PaymentReset: {
|
||||
target: "ready",
|
||||
actions: "resetOrder",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tipMessageFailedState = paymentMachineSetup.createStateConfig({
|
||||
on: {
|
||||
PaymentTipMessageRetryRequested: { target: "loadingTipMessage" },
|
||||
PaymentPlanSelected: {
|
||||
target: "ready",
|
||||
actions: "selectPlan",
|
||||
},
|
||||
PaymentPayChannelChanged: {
|
||||
target: "ready",
|
||||
actions: "changePayChannel",
|
||||
},
|
||||
PaymentReset: {
|
||||
target: "ready",
|
||||
actions: "resetOrder",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const paidState = paymentMachineSetup.createStateConfig({
|
||||
on: {
|
||||
PaymentPlanSelected: {
|
||||
@@ -269,6 +342,42 @@ const paidState = paymentMachineSetup.createStateConfig({
|
||||
|
||||
const failedState = paymentMachineSetup.createStateConfig({
|
||||
on: {
|
||||
PaymentPlanSelected: {
|
||||
target: "ready",
|
||||
actions: "selectPlan",
|
||||
},
|
||||
PaymentPayChannelChanged: {
|
||||
target: "ready",
|
||||
actions: "changePayChannel",
|
||||
},
|
||||
PaymentCreateOrderSubmitted: [
|
||||
{
|
||||
guard: "canCreateOrder",
|
||||
target: "creatingOrder",
|
||||
actions: "resetOrder",
|
||||
},
|
||||
],
|
||||
PaymentErrorCleared: {
|
||||
target: "ready",
|
||||
actions: "clearPaymentError",
|
||||
},
|
||||
PaymentReset: {
|
||||
target: "ready",
|
||||
actions: "resetOrder",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const expiredState = paymentMachineSetup.createStateConfig({
|
||||
on: {
|
||||
PaymentPlanSelected: {
|
||||
target: "ready",
|
||||
actions: "selectPlan",
|
||||
},
|
||||
PaymentPayChannelChanged: {
|
||||
target: "ready",
|
||||
actions: "changePayChannel",
|
||||
},
|
||||
PaymentCreateOrderSubmitted: [
|
||||
{
|
||||
guard: "canCreateOrder",
|
||||
@@ -293,15 +402,30 @@ export const paymentRootStateConfig = paymentMachineSetup.createStateConfig({
|
||||
idle: idleState,
|
||||
loadingCachedPlans: loadingCachedPlansState,
|
||||
loadingPlans: loadingPlansState,
|
||||
loadingGiftProducts: loadingGiftProductsState,
|
||||
refreshingPlans: refreshingPlansState,
|
||||
ready: readyState,
|
||||
creatingOrder: creatingOrderState,
|
||||
pollingOrder: pollingOrderState,
|
||||
waitingForPayment: waitingForPaymentState,
|
||||
loadingTipMessage: loadingTipMessageState,
|
||||
tipMessageFailed: tipMessageFailedState,
|
||||
paid: paidState,
|
||||
failed: failedState,
|
||||
expired: expiredState,
|
||||
},
|
||||
on: {
|
||||
PaymentInit: [
|
||||
{
|
||||
guard: "isTipInit",
|
||||
target: ".loadingGiftProducts",
|
||||
actions: "refreshCatalog",
|
||||
},
|
||||
{
|
||||
target: ".refreshingPlans",
|
||||
actions: "refreshCatalog",
|
||||
},
|
||||
],
|
||||
PaymentFirstRechargeConsumed: { actions: "consumeFirstRecharge" },
|
||||
PaymentReturned: {
|
||||
target: ".pollingOrder",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createPaymentOrderActor,
|
||||
pollPaymentOrderStatusActor,
|
||||
} from "./actors/order";
|
||||
import { loadGiftProductsActor, loadTipMessageActor } from "./actors/gifts";
|
||||
import {
|
||||
loadCachedPaymentPlansActor,
|
||||
refreshPaymentPlansActor,
|
||||
@@ -19,6 +20,8 @@ export const basePaymentMachineSetup = setup({
|
||||
actors: {
|
||||
loadCachedPlans: loadCachedPaymentPlansActor,
|
||||
refreshPlans: refreshPaymentPlansActor,
|
||||
loadGiftProducts: loadGiftProductsActor,
|
||||
loadTipMessage: loadTipMessageActor,
|
||||
createOrder: createPaymentOrderActor,
|
||||
pollOrderStatus: pollPaymentOrderStatusActor,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,11 @@ import type {
|
||||
|
||||
export interface PaymentContextState {
|
||||
status: string;
|
||||
planCatalog: MachineContext["planCatalog"];
|
||||
plans: MachineContext["plans"];
|
||||
giftCategories: MachineContext["giftCategories"];
|
||||
giftProducts: MachineContext["giftProducts"];
|
||||
selectedGiftCategory: string | null;
|
||||
isFirstRecharge: MachineContext["isFirstRecharge"];
|
||||
selectedPlanId: string;
|
||||
payChannel: MachineContext["payChannel"];
|
||||
@@ -22,11 +26,12 @@ export interface PaymentContextState {
|
||||
currentOrderId: string | null;
|
||||
payParams: Record<string, unknown> | null;
|
||||
orderStatus: MachineContext["orderStatus"];
|
||||
tipCount: number | null;
|
||||
thankYouMessage: string | null;
|
||||
tipMessage: MachineContext["tipMessage"];
|
||||
tipMessageError: string | null;
|
||||
errorMessage: string | null;
|
||||
launchNonce: number;
|
||||
isLoadingPlans: boolean;
|
||||
isLoadingTipMessage: boolean;
|
||||
isCreatingOrder: boolean;
|
||||
isPollingOrder: boolean;
|
||||
isPaid: boolean;
|
||||
@@ -63,7 +68,11 @@ export function usePaymentSelector<T>(
|
||||
function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
|
||||
return {
|
||||
status: String(state.value),
|
||||
planCatalog: state.context.planCatalog,
|
||||
plans: state.context.plans,
|
||||
giftCategories: state.context.giftCategories,
|
||||
giftProducts: state.context.giftProducts,
|
||||
selectedGiftCategory: state.context.selectedGiftCategory,
|
||||
isFirstRecharge: state.context.isFirstRecharge,
|
||||
selectedPlanId: state.context.selectedPlanId,
|
||||
payChannel: state.context.payChannel,
|
||||
@@ -72,17 +81,19 @@ 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,
|
||||
tipMessage: state.context.tipMessage,
|
||||
tipMessageError: state.context.tipMessageError,
|
||||
errorMessage: state.context.errorMessage,
|
||||
launchNonce: state.context.launchNonce,
|
||||
isLoadingPlans:
|
||||
state.matches("loadingCachedPlans") ||
|
||||
state.matches("loadingPlans") ||
|
||||
state.matches("loadingGiftProducts") ||
|
||||
state.matches("refreshingPlans"),
|
||||
isLoadingTipMessage: state.matches("loadingTipMessage"),
|
||||
isCreatingOrder: state.matches("creatingOrder"),
|
||||
isPollingOrder:
|
||||
state.matches("pollingOrder") || state.matches("waitingForPayment"),
|
||||
isPaid: state.matches("paid"),
|
||||
isPaid: state.context.orderStatus === "paid",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ export type PaymentEvent =
|
||||
type: "PaymentInit";
|
||||
payChannel?: PayChannel;
|
||||
catalog?: PaymentPlanCatalog;
|
||||
characterId?: string;
|
||||
category?: string | null;
|
||||
planId?: string | null;
|
||||
}
|
||||
| { type: "PaymentPlanSelected"; planId: string }
|
||||
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
|
||||
@@ -19,5 +22,7 @@ export type PaymentEvent =
|
||||
| { type: "PaymentReturned"; orderId: string; createdAt?: number }
|
||||
| { type: "PaymentLaunchFailed"; errorMessage: string }
|
||||
| { type: "PaymentFirstRechargeConsumed" }
|
||||
| { type: "PaymentCatalogRetryRequested" }
|
||||
| { type: "PaymentTipMessageRetryRequested" }
|
||||
| { type: "PaymentErrorCleared" }
|
||||
| { type: "PaymentReset" };
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/** Context shared by the default and Tip payment catalogs. */
|
||||
import type {
|
||||
GiftCategory,
|
||||
GiftProduct,
|
||||
PayChannel,
|
||||
PaymentOrderStatus,
|
||||
PaymentPlan,
|
||||
TipMessageResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
export type PaymentPlanCatalog = "default" | "tip";
|
||||
@@ -10,6 +13,12 @@ export type PaymentPlanCatalog = "default" | "tip";
|
||||
export interface PaymentState {
|
||||
planCatalog: PaymentPlanCatalog;
|
||||
plans: readonly PaymentPlan[];
|
||||
giftCategories: readonly GiftCategory[];
|
||||
giftProducts: readonly GiftProduct[];
|
||||
giftCharacterId: string | null;
|
||||
selectedGiftCategory: string | null;
|
||||
requestedGiftCategory: string | null;
|
||||
requestedGiftPlanId: string | null;
|
||||
isFirstRecharge: boolean;
|
||||
selectedPlanId: string;
|
||||
payChannel: PayChannel;
|
||||
@@ -19,8 +28,8 @@ export interface PaymentState {
|
||||
currentOrderPlanId: string | null;
|
||||
payParams: Record<string, unknown> | null;
|
||||
orderStatus: PaymentOrderStatus | null;
|
||||
tipCount: number | null;
|
||||
thankYouMessage: string | null;
|
||||
tipMessage: TipMessageResponse | null;
|
||||
tipMessageError: string | null;
|
||||
orderPollingStartedAt: number | null;
|
||||
errorMessage: string | null;
|
||||
launchNonce: number;
|
||||
@@ -29,6 +38,12 @@ export interface PaymentState {
|
||||
export const initialState: PaymentState = {
|
||||
planCatalog: "default",
|
||||
plans: [],
|
||||
giftCategories: [],
|
||||
giftProducts: [],
|
||||
giftCharacterId: null,
|
||||
selectedGiftCategory: null,
|
||||
requestedGiftCategory: null,
|
||||
requestedGiftPlanId: null,
|
||||
isFirstRecharge: false,
|
||||
selectedPlanId: "",
|
||||
payChannel: "stripe",
|
||||
@@ -38,8 +53,8 @@ export const initialState: PaymentState = {
|
||||
currentOrderPlanId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
launchNonce: 0,
|
||||
|
||||
Reference in New Issue
Block a user