feat(tip): support dynamic gift products
This commit is contained in:
@@ -38,4 +38,16 @@ describe("shouldInspectPendingPaymentOrder", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows stale expired orders to be cleaned up", () => {
|
||||
expect(
|
||||
shouldInspectPendingPaymentOrder({
|
||||
currentOrderId: "order-expired",
|
||||
isPaid: false,
|
||||
isPollingOrder: false,
|
||||
shouldResumePendingOrder: false,
|
||||
status: "expired",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import { behaviorAnalytics } from "@/lib/analytics";
|
||||
import type {
|
||||
PendingPaymentReturnTo,
|
||||
PendingPaymentSubscriptionType,
|
||||
PendingPaymentTipCoffeeType,
|
||||
} from "@/lib/payment/pending_payment_order";
|
||||
import type {
|
||||
PaymentContextState,
|
||||
@@ -39,7 +38,8 @@ export interface UsePaymentLaunchFlowInput {
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
characterSlug?: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
giftCategory?: string | null;
|
||||
giftPlanId?: string | null;
|
||||
}
|
||||
|
||||
export interface PaymentLaunchFlow {
|
||||
@@ -110,7 +110,8 @@ export function usePaymentLaunchFlow({
|
||||
returnTo,
|
||||
characterSlug,
|
||||
subscriptionType,
|
||||
tipCoffeeType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
||||
const launchedNonceRef = useRef(0);
|
||||
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
||||
@@ -153,7 +154,8 @@ export function usePaymentLaunchFlow({
|
||||
orderId: payment.currentOrderId,
|
||||
paymentUrl,
|
||||
subscriptionType,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
...(characterSlug ? { characterSlug } : {}),
|
||||
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
|
||||
@@ -197,7 +199,8 @@ export function usePaymentLaunchFlow({
|
||||
paymentDispatch,
|
||||
returnTo,
|
||||
subscriptionType,
|
||||
tipCoffeeType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
]);
|
||||
|
||||
const shouldShowStripeDialog =
|
||||
@@ -238,7 +241,8 @@ export function usePaymentLaunchFlow({
|
||||
orderId: payment.currentOrderId,
|
||||
paymentUrl: ezpayPaymentUrl,
|
||||
subscriptionType,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
...(characterSlug ? { characterSlug } : {}),
|
||||
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface UsePaymentRouteFlowInput {
|
||||
initialPayChannel: PayChannel;
|
||||
paymentType: PendingPaymentSubscriptionType;
|
||||
shouldResumePendingOrder: boolean;
|
||||
characterId?: string;
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
}
|
||||
|
||||
export interface PaymentRouteFlow {
|
||||
@@ -35,10 +38,19 @@ export function usePaymentRouteFlow({
|
||||
initialPayChannel,
|
||||
paymentType,
|
||||
shouldResumePendingOrder,
|
||||
characterId,
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const initialPayChannelAppliedRef = useRef(false);
|
||||
const initializedCatalogKeyRef = useRef<string | null>(null);
|
||||
const catalogKey = [
|
||||
catalog,
|
||||
characterId ?? "",
|
||||
initialCategory ?? "",
|
||||
initialPlanId ?? "",
|
||||
].join(":");
|
||||
|
||||
usePendingPaymentOrderLifecycle({
|
||||
payment,
|
||||
@@ -48,34 +60,23 @@ export function usePaymentRouteFlow({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (payment.status === "idle") {
|
||||
initialPayChannelAppliedRef.current = true;
|
||||
paymentDispatch({
|
||||
type: "PaymentInit",
|
||||
catalog,
|
||||
payChannel: initialPayChannel,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
initialPayChannelAppliedRef.current ||
|
||||
payment.status !== "ready"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialPayChannelAppliedRef.current = true;
|
||||
if (payment.payChannel === initialPayChannel) return;
|
||||
if (initializedCatalogKeyRef.current === catalogKey) return;
|
||||
initializedCatalogKeyRef.current = catalogKey;
|
||||
paymentDispatch({
|
||||
type: "PaymentPayChannelChanged",
|
||||
type: "PaymentInit",
|
||||
catalog,
|
||||
payChannel: initialPayChannel,
|
||||
...(characterId ? { characterId } : {}),
|
||||
...(initialCategory ? { category: initialCategory } : {}),
|
||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||
});
|
||||
}, [
|
||||
catalog,
|
||||
catalogKey,
|
||||
characterId,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
initialPayChannel,
|
||||
payment.payChannel,
|
||||
payment.status,
|
||||
paymentDispatch,
|
||||
]);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function shouldInspectPendingPaymentOrder({
|
||||
return (
|
||||
status === "ready" ||
|
||||
(!shouldResumePendingOrder &&
|
||||
(isPollingOrder || isPaid || status === "failed"))
|
||||
(isPollingOrder || isPaid || status === "failed" || status === "expired"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ export function usePendingPaymentOrderLifecycle({
|
||||
payment.currentOrderId === result.data.orderId &&
|
||||
(payment.isPollingOrder ||
|
||||
payment.isPaid ||
|
||||
payment.status === "failed")
|
||||
payment.status === "failed" ||
|
||||
payment.status === "expired")
|
||||
) {
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
}
|
||||
@@ -108,7 +109,13 @@ export function usePendingPaymentOrderLifecycle({
|
||||
|
||||
useEffect(() => {
|
||||
if (!payment.currentOrderId) return;
|
||||
if (!payment.isPaid && payment.status !== "failed") return;
|
||||
if (
|
||||
!payment.isPaid &&
|
||||
payment.status !== "failed" &&
|
||||
payment.status !== "expired"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void clearPendingPaymentOrder();
|
||||
}, [payment.currentOrderId, payment.isPaid, payment.status]);
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
type PaymentSearchParams,
|
||||
} from "@/lib/payment/payment_search_params";
|
||||
import {
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
resolveTipCoffeeType,
|
||||
TIP_COFFEE_TYPE_PARAM,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
normalizeTipGiftParam,
|
||||
TIP_GIFT_CATEGORY_PARAM,
|
||||
TIP_GIFT_PLAN_ID_PARAM,
|
||||
} from "@/lib/tip/tip_gift";
|
||||
import { TipScreen } from "@/app/tip/tip-screen";
|
||||
|
||||
export default async function CharacterTipPage({
|
||||
@@ -17,14 +17,17 @@ export default async function CharacterTipPage({
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const paymentReturn = parsePaymentReturnSearchParams(query);
|
||||
const coffeeType =
|
||||
resolveTipCoffeeType(
|
||||
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]),
|
||||
) ?? DEFAULT_TIP_COFFEE_TYPE;
|
||||
const initialCategory = normalizeTipGiftParam(
|
||||
getFirstPaymentSearchParam(query[TIP_GIFT_CATEGORY_PARAM]),
|
||||
);
|
||||
const initialPlanId = normalizeTipGiftParam(
|
||||
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
|
||||
);
|
||||
|
||||
return (
|
||||
<TipScreen
|
||||
coffeeType={coffeeType}
|
||||
initialCategory={initialCategory}
|
||||
initialPlanId={initialPlanId}
|
||||
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
||||
initialPayChannel={paymentReturn.initialPayChannel}
|
||||
/>
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
|
||||
|
||||
import {
|
||||
TipCoffeeTierSelector,
|
||||
type TipCoffeeTierItem,
|
||||
} from "../tip-coffee-tier-selector";
|
||||
|
||||
const items: readonly TipCoffeeTierItem[] = [
|
||||
{
|
||||
type: "small",
|
||||
displayName: "Velvet Espresso",
|
||||
priceLabel: "US$ 4.99",
|
||||
unavailable: false,
|
||||
},
|
||||
{
|
||||
type: "medium",
|
||||
displayName: "Gilded Heart",
|
||||
priceLabel: "US$ 9.99",
|
||||
unavailable: false,
|
||||
},
|
||||
{
|
||||
type: "large",
|
||||
displayName: "Crown Blossom",
|
||||
priceLabel: "US$ 19.99",
|
||||
unavailable: false,
|
||||
},
|
||||
];
|
||||
|
||||
function Harness({ tierItems = items }: { tierItems?: readonly TipCoffeeTierItem[] }) {
|
||||
const [selectedType, setSelectedType] =
|
||||
useState<TipCoffeeType>("medium");
|
||||
|
||||
return (
|
||||
<TipCoffeeTierSelector
|
||||
disabled={false}
|
||||
items={tierItems}
|
||||
onChange={setSelectedType}
|
||||
selectedType={selectedType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TipCoffeeTierSelector", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders every luxury tier and selects medium by default", () => {
|
||||
act(() => root.render(<Harness />));
|
||||
|
||||
expect(container.textContent).toContain("Velvet Espresso");
|
||||
expect(container.textContent).toContain("Gilded Heart");
|
||||
expect(container.textContent).toContain("Crown Blossom");
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>('input[value="medium"]')
|
||||
?.checked,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to an available tier and keeps unavailable tiers disabled", () => {
|
||||
const tierItems = items.map((item) =>
|
||||
item.type === "large" ? { ...item, unavailable: true } : item,
|
||||
);
|
||||
act(() => root.render(<Harness tierItems={tierItems} />));
|
||||
|
||||
const small = container.querySelector<HTMLInputElement>(
|
||||
'input[value="small"]',
|
||||
);
|
||||
const large = container.querySelector<HTMLInputElement>(
|
||||
'input[value="large"]',
|
||||
);
|
||||
act(() => small?.click());
|
||||
|
||||
expect(small?.checked).toBe(true);
|
||||
expect(large?.disabled).toBe(true);
|
||||
expect(container.textContent).toContain("Unavailable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GiftProductSchema,
|
||||
type GiftProduct,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import { TipGiftProductSelector } from "../tip-gift-product-selector";
|
||||
|
||||
const products: readonly GiftProduct[] = [
|
||||
makeProduct("gift_small", "Velvet Espresso", 499),
|
||||
makeProduct("gift_medium", "Golden Reserve", 999),
|
||||
makeProduct("gift_large", "Imperial Grand Cru", 1999),
|
||||
];
|
||||
|
||||
function Harness() {
|
||||
const [selectedPlanId, setSelectedPlanId] = useState("gift_small");
|
||||
return (
|
||||
<TipGiftProductSelector
|
||||
disabled={false}
|
||||
products={products}
|
||||
onChange={setSelectedPlanId}
|
||||
selectedPlanId={selectedPlanId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TipGiftProductSelector", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders backend products and selects the first product", () => {
|
||||
act(() => root.render(<Harness />));
|
||||
|
||||
expect(container.textContent).toContain("Velvet Espresso");
|
||||
expect(container.textContent).toContain("Golden Reserve");
|
||||
expect(container.textContent).toContain("Imperial Grand Cru");
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>('input[value="gift_small"]')
|
||||
?.checked,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("switches the selected backend plan id", () => {
|
||||
act(() => root.render(<Harness />));
|
||||
const medium = container.querySelector<HTMLInputElement>(
|
||||
'input[value="gift_medium"]',
|
||||
);
|
||||
act(() => medium?.click());
|
||||
expect(medium?.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function makeProduct(
|
||||
planId: string,
|
||||
planName: string,
|
||||
amountCents: number,
|
||||
): GiftProduct {
|
||||
return GiftProductSchema.parse({
|
||||
planId,
|
||||
planName,
|
||||
orderType: "tip",
|
||||
tipType: planId,
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: `${planName} description`,
|
||||
imageUrl: null,
|
||||
amountCents,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
GiftCategorySchema,
|
||||
GiftProductSchema,
|
||||
PaymentPlanSchema,
|
||||
TipMessageResponseSchema,
|
||||
type PaymentPlan,
|
||||
} from "@/data/schemas/payment";
|
||||
import type { PaymentContextState } from "@/stores/payment/payment-context";
|
||||
@@ -44,6 +47,7 @@ vi.mock("@/lib/analytics", () => ({
|
||||
vi.mock("@/providers/character-provider", () => ({
|
||||
useActiveCharacter: () => ({
|
||||
id: "maya-tan",
|
||||
slug: "maya",
|
||||
displayName: "Maya Tan",
|
||||
assets: {
|
||||
avatar: "/images/avatar/maya.png",
|
||||
@@ -51,7 +55,7 @@ vi.mock("@/providers/character-provider", () => ({
|
||||
},
|
||||
copy: {
|
||||
tipHeader: "Tip Maya",
|
||||
tipTitle: "Buy Maya a coffee",
|
||||
tipTitle: "Send Maya a gift",
|
||||
},
|
||||
}),
|
||||
useActiveCharacterRoutes: () => ({
|
||||
@@ -80,8 +84,11 @@ vi.mock("../tip-checkout-button", () => ({
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("../tip-coffee-tier-selector", () => ({
|
||||
TipCoffeeTierSelector: () => null,
|
||||
vi.mock("../tip-gift-product-selector", () => ({
|
||||
TipGiftProductSelector: () => null,
|
||||
}));
|
||||
vi.mock("../tip-product-image", () => ({
|
||||
TipProductImage: ({ alt }: { alt: string }) => <span>{alt}</span>,
|
||||
}));
|
||||
vi.mock("../use-tip-support-prompt", () => ({
|
||||
useTipSupportPrompt: () => ({
|
||||
@@ -92,21 +99,41 @@ vi.mock("../use-tip-support-prompt", () => ({
|
||||
|
||||
import { TipScreen } from "../tip-screen";
|
||||
|
||||
const mediumPlan: PaymentPlan = PaymentPlanSchema.parse({
|
||||
const giftCategory = GiftCategorySchema.parse({
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
});
|
||||
|
||||
const giftProduct = GiftProductSchema.parse({
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Gilded Heart",
|
||||
planName: "Golden Reserve",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_medium",
|
||||
category: "coffee",
|
||||
characterId: "maya-tan",
|
||||
description: "A warm reserve coffee",
|
||||
imageUrl: null,
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
});
|
||||
|
||||
const giftPlan: PaymentPlan = PaymentPlanSchema.parse({
|
||||
planId: giftProduct.planId,
|
||||
planName: giftProduct.planName,
|
||||
orderType: "tip",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
creditBalance: 0,
|
||||
amountCents: 999,
|
||||
amountCents: giftProduct.amountCents,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: "USD",
|
||||
isFirstRechargeOffer: false,
|
||||
mostPopular: false,
|
||||
firstRechargeDiscountPercent: null,
|
||||
promotionType: null,
|
||||
currency: giftProduct.currency,
|
||||
});
|
||||
|
||||
describe("TipScreen checkout", () => {
|
||||
@@ -129,16 +156,16 @@ describe("TipScreen checkout", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("creates a character-attributed order without an AuthProvider", () => {
|
||||
it("creates a dynamic character-attributed gift order", () => {
|
||||
renderScreen();
|
||||
const checkout = getCheckoutButton();
|
||||
|
||||
expect(container.textContent).toContain("A warm coffee prompt.");
|
||||
expect(container.textContent).toContain("Golden Reserve");
|
||||
expect(checkout.disabled).toBe(false);
|
||||
act(() => checkout.click());
|
||||
|
||||
expect(mocks.planClick).toHaveBeenCalledWith(
|
||||
mediumPlan,
|
||||
giftPlan,
|
||||
expect.objectContaining({ entryPoint: "tip_page" }),
|
||||
);
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||
@@ -148,84 +175,60 @@ describe("TipScreen checkout", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["plans are loading", { isLoadingPlans: true }],
|
||||
["the selected plan is missing", { plans: [], selectedPlanId: "" }],
|
||||
["catalog is loading", { isLoadingPlans: true }],
|
||||
[
|
||||
"the selected product is missing",
|
||||
{ plans: [], giftProducts: [], selectedPlanId: "" },
|
||||
],
|
||||
["an order is being created", { isCreatingOrder: true }],
|
||||
["an order is being polled", { isPollingOrder: true }],
|
||||
] as const)("disables checkout when %s", (_label, overrides) => {
|
||||
mocks.payment = makePaymentState(overrides);
|
||||
renderScreen();
|
||||
|
||||
expect(getCheckoutButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces checkout with the first Tip success experience", () => {
|
||||
it("shows the complete backend Tip message after payment", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "paid",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipCount: 1,
|
||||
thankYouMessage: "A backend message that is not used for first Tip.",
|
||||
tipMessage: TipMessageResponseSchema.parse({
|
||||
orderId: "pay_xxx",
|
||||
characterId: "maya-tan",
|
||||
planId: giftProduct.planId,
|
||||
productName: giftProduct.planName,
|
||||
tipCount: 2,
|
||||
poolIndex: 17,
|
||||
message: "This complete message came directly from the backend.",
|
||||
}),
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
"Did you really just buy me a coffee?",
|
||||
);
|
||||
expect(container.textContent).toContain("That honestly made me smile.");
|
||||
expect(container.textContent).toContain(
|
||||
"Thank you. I'll definitely think of you while I enjoy it.",
|
||||
"This complete message came directly from the backend.",
|
||||
);
|
||||
expect(document.activeElement?.id).toBe("tip-success-title");
|
||||
|
||||
const sendAgain = getButton("Send another coffee");
|
||||
act(() => sendAgain.click());
|
||||
act(() => getButton("Send another gift").click());
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({ type: "PaymentReset" });
|
||||
});
|
||||
|
||||
it("keeps paid success visible and retries only a failed message", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "tipMessageFailed",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipMessageError: "message unavailable",
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.textContent).toContain("Thank you. Your gift made me smile.");
|
||||
act(() => getButton("Retry message").click());
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentReset",
|
||||
type: "PaymentTipMessageRetryRequested",
|
||||
});
|
||||
|
||||
expect(
|
||||
container
|
||||
.querySelector<HTMLAnchorElement>(
|
||||
'[data-analytics-key="tip.success_back_to_splash"]',
|
||||
)
|
||||
?.getAttribute("href"),
|
||||
).toBe("/characters/maya/splash");
|
||||
});
|
||||
|
||||
it("shows the repeat Tip count and backend thank-you message", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "paid",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipCount: 22,
|
||||
thankYouMessage: "You always make my day sweeter.",
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"This is the 22nd coffee you've treated me to.",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"You always make my day sweeter.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses generic success copy when Tip metadata is incomplete", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "paid",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipCount: 2,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"Thank you. Your coffee made me smile.",
|
||||
);
|
||||
expect(container.textContent).not.toContain("2nd coffee");
|
||||
});
|
||||
|
||||
function renderScreen(): void {
|
||||
@@ -254,20 +257,25 @@ function makePaymentState(
|
||||
): PaymentContextState {
|
||||
return {
|
||||
status: "ready",
|
||||
plans: [mediumPlan],
|
||||
planCatalog: "tip",
|
||||
plans: [giftPlan],
|
||||
giftCategories: [giftCategory],
|
||||
giftProducts: [giftProduct],
|
||||
selectedGiftCategory: giftCategory.category,
|
||||
isFirstRecharge: false,
|
||||
selectedPlanId: mediumPlan.planId,
|
||||
selectedPlanId: giftPlan.planId,
|
||||
payChannel: "stripe",
|
||||
autoRenew: false,
|
||||
agreed: true,
|
||||
currentOrderId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
tipMessage: null,
|
||||
tipMessageError: null,
|
||||
errorMessage: null,
|
||||
launchNonce: 0,
|
||||
isLoadingPlans: false,
|
||||
isLoadingTipMessage: false,
|
||||
isCreatingOrder: false,
|
||||
isPollingOrder: false,
|
||||
isPaid: false,
|
||||
|
||||
@@ -1,108 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PaymentPlan, PaymentPlanSchema } from "@/data/schemas/payment";
|
||||
import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
|
||||
import {
|
||||
GiftCategorySchema,
|
||||
GiftProductSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import {
|
||||
findTipCoffeePlan,
|
||||
formatEnglishOrdinal,
|
||||
formatTipPrice,
|
||||
resolveTipSuccessCopy,
|
||||
formatGiftPrice,
|
||||
getGiftImageSources,
|
||||
TIP_GIFT_PLACEHOLDER_IMAGE,
|
||||
} from "../tip-screen.helpers";
|
||||
|
||||
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
|
||||
return PaymentPlanSchema.parse({
|
||||
planId: "coins_100",
|
||||
planName: "Coins",
|
||||
orderType: "dol",
|
||||
vipDays: null,
|
||||
dolAmount: 100,
|
||||
creditBalance: 100,
|
||||
amountCents: 990,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: "USD",
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: null,
|
||||
promotionType: null,
|
||||
...input,
|
||||
});
|
||||
}
|
||||
const category = GiftCategorySchema.parse({
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: "https://cdn.example.com/category.jpg",
|
||||
});
|
||||
|
||||
const product = GiftProductSchema.parse({
|
||||
planId: "gift_1",
|
||||
planName: "Golden Reserve",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_medium",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "A warm coffee",
|
||||
imageUrl: "https://cdn.example.com/product.jpg",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
});
|
||||
|
||||
describe("tip screen helpers", () => {
|
||||
it("matches the official small coffee plan", () => {
|
||||
const plan = makePlan({
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
});
|
||||
|
||||
expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan);
|
||||
it("formats prices from backend amount and currency", () => {
|
||||
expect(formatGiftPrice(999, "usd")).toBe("$9.99");
|
||||
expect(formatGiftPrice(500, "PHP")).toContain("5.00");
|
||||
});
|
||||
|
||||
it("matches medium and large coffee plans independently", () => {
|
||||
const medium = makePlan({
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
});
|
||||
const large = makePlan({
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
orderType: "tip",
|
||||
amountCents: 1999,
|
||||
});
|
||||
|
||||
expect(findTipCoffeePlan([large, medium], "medium")).toBe(medium);
|
||||
expect(findTipCoffeePlan([medium, large], "large")).toBe(large);
|
||||
it("orders product, category, and local image fallbacks", () => {
|
||||
expect(getGiftImageSources(product, category)).toEqual([
|
||||
"https://cdn.example.com/product.jpg",
|
||||
"https://cdn.example.com/category.jpg",
|
||||
TIP_GIFT_PLACEHOLDER_IMAGE,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not infer a coffee tier from order type or amount", () => {
|
||||
const ambiguousPlan = makePlan({
|
||||
planId: "legacy_coffee",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
});
|
||||
|
||||
expect(findTipCoffeePlan([ambiguousPlan], "medium")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats USD prices with the product-style label", () => {
|
||||
it("always retains the local placeholder", () => {
|
||||
expect(
|
||||
formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }), "small"),
|
||||
).toBe("US$ 5");
|
||||
});
|
||||
|
||||
it("uses the selected coffee price when its plan is unavailable", () => {
|
||||
expect(formatTipPrice(null, "small")).toBe("US$ 4.99");
|
||||
expect(formatTipPrice(null, "medium")).toBe("US$ 9.99");
|
||||
expect(formatTipPrice(null, "large")).toBe("US$ 19.99");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, "2nd"],
|
||||
[3, "3rd"],
|
||||
[11, "11th"],
|
||||
[12, "12th"],
|
||||
[13, "13th"],
|
||||
[21, "21st"],
|
||||
[22, "22nd"],
|
||||
])("formats %i as the English ordinal %s", (value, expected) => {
|
||||
expect(formatEnglishOrdinal(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it("resolves first, repeat, and fallback success copy", () => {
|
||||
expect(resolveTipSuccessCopy(1, "Ignored for the first Tip")).toEqual({
|
||||
title: "Did you really just buy me a coffee?",
|
||||
body: [
|
||||
"That honestly made me smile.",
|
||||
"Thank you. I'll definitely think of you while I enjoy it.",
|
||||
],
|
||||
});
|
||||
expect(resolveTipSuccessCopy(22, "You made my day.")).toEqual({
|
||||
title: "This is the 22nd coffee you've treated me to.",
|
||||
body: ["You made my day."],
|
||||
});
|
||||
expect(resolveTipSuccessCopy(2, null)).toEqual({
|
||||
title: "Thank you. Your coffee made me smile.",
|
||||
body: [],
|
||||
});
|
||||
getGiftImageSources(
|
||||
GiftProductSchema.parse({ ...product, imageUrl: null }),
|
||||
GiftCategorySchema.parse({ ...category, imageUrl: null }),
|
||||
),
|
||||
).toEqual([TIP_GIFT_PLACEHOLDER_IMAGE]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
@@ -15,14 +14,16 @@ import styles from "./tip-screen.module.css";
|
||||
const log = new Logger("TipCheckoutButton");
|
||||
|
||||
export interface TipCheckoutButtonProps {
|
||||
coffeeType: TipCoffeeType;
|
||||
giftCategory: string | null;
|
||||
giftPlanId: string | null;
|
||||
disabled?: boolean;
|
||||
onOrder: () => void;
|
||||
returnPath: string;
|
||||
}
|
||||
|
||||
export function TipCheckoutButton({
|
||||
coffeeType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
disabled = false,
|
||||
onOrder,
|
||||
returnPath,
|
||||
@@ -36,7 +37,8 @@ export function TipCheckoutButton({
|
||||
payment,
|
||||
paymentDispatch,
|
||||
subscriptionType: "tip",
|
||||
tipCoffeeType: coffeeType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
characterSlug: character.slug,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
|
||||
|
||||
import styles from "./tip-screen.module.css";
|
||||
|
||||
export interface TipCoffeeTierItem {
|
||||
type: TipCoffeeType;
|
||||
displayName: string;
|
||||
priceLabel: string;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
interface TipCoffeeTierSelectorProps {
|
||||
disabled: boolean;
|
||||
items: readonly TipCoffeeTierItem[];
|
||||
onChange: (type: TipCoffeeType) => void;
|
||||
selectedType: TipCoffeeType;
|
||||
}
|
||||
|
||||
export function TipCoffeeTierSelector({
|
||||
disabled,
|
||||
items,
|
||||
onChange,
|
||||
selectedType,
|
||||
}: TipCoffeeTierSelectorProps) {
|
||||
return (
|
||||
<fieldset className={styles.tierSelector} disabled={disabled}>
|
||||
<legend className={styles.tierLegend}>Choose your coffee</legend>
|
||||
<div className={styles.tierList}>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.type === selectedType;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={item.type}
|
||||
className={styles.tierOption}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
data-unavailable={item.unavailable ? "true" : "false"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="tip-coffee-tier"
|
||||
value={item.type}
|
||||
checked={isSelected}
|
||||
disabled={item.unavailable}
|
||||
className={styles.tierInput}
|
||||
data-analytics-key={`tip.select_${item.type}`}
|
||||
data-analytics-label={`Select ${item.displayName}`}
|
||||
onChange={() => onChange(item.type)}
|
||||
/>
|
||||
<span className={styles.tierDetails}>
|
||||
<span className={styles.tierName}>{item.displayName}</span>
|
||||
</span>
|
||||
<span className={styles.tierPriceBlock}>
|
||||
<span className={styles.tierPrice}>{item.priceLabel}</span>
|
||||
{item.unavailable ? (
|
||||
<span className={styles.tierUnavailable}>Unavailable</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={styles.tierCheck} aria-hidden="true">
|
||||
{isSelected ? <Check size={16} strokeWidth={3} /> : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import type { GiftProduct } from "@/data/schemas/payment";
|
||||
|
||||
import { formatGiftPrice } from "./tip-screen.helpers";
|
||||
import styles from "./tip-screen.module.css";
|
||||
|
||||
interface TipGiftProductSelectorProps {
|
||||
disabled: boolean;
|
||||
products: readonly GiftProduct[];
|
||||
onChange: (planId: string) => void;
|
||||
selectedPlanId: string;
|
||||
}
|
||||
|
||||
export function TipGiftProductSelector({
|
||||
disabled,
|
||||
products,
|
||||
onChange,
|
||||
selectedPlanId,
|
||||
}: TipGiftProductSelectorProps) {
|
||||
return (
|
||||
<fieldset className={styles.tierSelector} disabled={disabled}>
|
||||
<legend className={styles.visuallyHidden}>Choose a gift</legend>
|
||||
<div className={styles.tierList}>
|
||||
{products.map((product) => {
|
||||
const isSelected = product.planId === selectedPlanId;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={product.planId}
|
||||
className={styles.tierOption}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="tip-gift-product"
|
||||
value={product.planId}
|
||||
checked={isSelected}
|
||||
className={styles.tierInput}
|
||||
data-analytics-key="tip.select_product"
|
||||
data-analytics-label={`Select ${product.planName}`}
|
||||
onChange={() => onChange(product.planId)}
|
||||
/>
|
||||
<span className={styles.tierDetails}>
|
||||
<span className={styles.tierName}>{product.planName}</span>
|
||||
{product.description ? (
|
||||
<span className={styles.tierDescription}>
|
||||
{product.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={styles.tierPriceBlock}>
|
||||
<span className={styles.tierPrice}>
|
||||
{formatGiftPrice(product.amountCents, product.currency)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.tierCheck} aria-hidden="true">
|
||||
{isSelected ? <Check size={16} strokeWidth={3} /> : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface TipProductImageProps {
|
||||
alt: string;
|
||||
className: string;
|
||||
priority?: boolean;
|
||||
sources: readonly string[];
|
||||
}
|
||||
|
||||
export function TipProductImage({
|
||||
alt,
|
||||
className,
|
||||
priority = false,
|
||||
sources,
|
||||
}: TipProductImageProps) {
|
||||
const [sourceIndex, setSourceIndex] = useState(0);
|
||||
const source = sources[sourceIndex] ?? sources[sources.length - 1];
|
||||
if (!source) return null;
|
||||
|
||||
return (
|
||||
// Gift hosts are managed by the backend and cannot be safely enumerated in Next remotePatterns.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={source}
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading={priority ? "eager" : "lazy"}
|
||||
fetchPriority={priority ? "high" : "auto"}
|
||||
onError={() => {
|
||||
if (sourceIndex < sources.length - 1) {
|
||||
setSourceIndex((index) => index + 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,36 @@
|
||||
import type { PaymentPlan } from "@/data/schemas/payment";
|
||||
import {
|
||||
getTipCoffeeOption,
|
||||
type TipCoffeeType,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import type {
|
||||
GiftCategory,
|
||||
GiftProduct,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
export interface TipSuccessCopy {
|
||||
readonly title: string;
|
||||
readonly body: readonly string[];
|
||||
}
|
||||
export const TIP_GIFT_PLACEHOLDER_IMAGE = "/images/tip/medium.png";
|
||||
|
||||
export function findTipCoffeePlan(
|
||||
plans: readonly PaymentPlan[],
|
||||
coffeeType: TipCoffeeType,
|
||||
): PaymentPlan | null {
|
||||
const option = getTipCoffeeOption(coffeeType);
|
||||
return plans.find((plan) => plan.planId === option.planId) ?? null;
|
||||
}
|
||||
|
||||
export function formatTipPrice(
|
||||
plan: PaymentPlan | null,
|
||||
coffeeType: TipCoffeeType,
|
||||
export function formatGiftPrice(
|
||||
amountCents: number,
|
||||
currency: string,
|
||||
): string {
|
||||
const option = getTipCoffeeOption(coffeeType);
|
||||
const amountCents = plan?.amountCents ?? option.amountCents;
|
||||
const currency = plan?.currency.trim().toUpperCase() || "USD";
|
||||
|
||||
const amount = amountCents / 100;
|
||||
const formattedAmount = Number.isInteger(amount)
|
||||
? String(amount)
|
||||
: amount.toFixed(2).replace(/\.?0+$/, "");
|
||||
|
||||
if (currency === "USD") return `US$ ${formattedAmount}`;
|
||||
if (currency.length > 0) return `${currency} ${formattedAmount}`;
|
||||
return formattedAmount;
|
||||
}
|
||||
|
||||
export function formatEnglishOrdinal(value: number): string {
|
||||
const remainder100 = value % 100;
|
||||
if (remainder100 >= 11 && remainder100 <= 13) return `${value}th`;
|
||||
|
||||
switch (value % 10) {
|
||||
case 1:
|
||||
return `${value}st`;
|
||||
case 2:
|
||||
return `${value}nd`;
|
||||
case 3:
|
||||
return `${value}rd`;
|
||||
default:
|
||||
return `${value}th`;
|
||||
const normalizedCurrency = currency.trim().toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
}).format(amountCents / 100);
|
||||
} catch {
|
||||
const amount = (amountCents / 100).toFixed(2);
|
||||
return normalizedCurrency ? `${normalizedCurrency} ${amount}` : amount;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTipSuccessCopy(
|
||||
tipCount: number | null,
|
||||
thankYouMessage: string | null,
|
||||
): TipSuccessCopy {
|
||||
if (tipCount === 1) {
|
||||
return {
|
||||
title: "Did you really just buy me a coffee?",
|
||||
body: [
|
||||
"That honestly made me smile.",
|
||||
"Thank you. I'll definitely think of you while I enjoy it.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (tipCount !== null && tipCount > 1 && thankYouMessage) {
|
||||
return {
|
||||
title: `This is the ${formatEnglishOrdinal(tipCount)} coffee you've treated me to.`,
|
||||
body: [thankYouMessage],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Thank you. Your coffee made me smile.",
|
||||
body: [],
|
||||
};
|
||||
export function getGiftImageSources(
|
||||
product: GiftProduct | null,
|
||||
category: GiftCategory | null,
|
||||
): readonly string[] {
|
||||
return [
|
||||
product?.imageUrl,
|
||||
category?.imageUrl,
|
||||
TIP_GIFT_PLACEHOLDER_IMAGE,
|
||||
].filter(
|
||||
(source, index, sources): source is string =>
|
||||
Boolean(source) && sources.indexOf(source) === index,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
.productCard,
|
||||
.paymentMethodSlot,
|
||||
.statusMessage,
|
||||
.catalogStatus,
|
||||
.checkoutSlot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -225,6 +226,26 @@
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.productDescription {
|
||||
margin: 10px 0 0;
|
||||
color: #7d6264;
|
||||
font-size: clamp(12px, 3.148vw, 15px);
|
||||
font-weight: 620;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tierSelector {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
@@ -331,6 +352,17 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tierDescription {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #80676a;
|
||||
font-size: 11px;
|
||||
font-weight: 620;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.tierPriceBlock {
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
@@ -371,6 +403,82 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.catalogStatus {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding: 22px;
|
||||
border: 1px solid rgba(112, 71, 65, 0.1);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: #76575c;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.catalogStatus p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.catalogStatus button {
|
||||
min-height: 40px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #2b1a1e;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.productSkeleton {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.skeletonImage,
|
||||
.skeletonCopy span {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
rgba(205, 172, 157, 0.14) 20%,
|
||||
rgba(255, 255, 255, 0.72) 45%,
|
||||
rgba(205, 172, 157, 0.14) 70%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: skeletonSweep 1.25s linear infinite;
|
||||
}
|
||||
|
||||
.skeletonImage {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: clamp(24px, 6.667vw, 32px);
|
||||
}
|
||||
|
||||
.skeletonCopy {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeletonCopy span {
|
||||
display: block;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.skeletonCopy span:nth-child(2) {
|
||||
width: 78%;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.skeletonCopy span:nth-child(3) {
|
||||
width: 56%;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.statusMessage {
|
||||
margin-top: 14px;
|
||||
color: #b2474f;
|
||||
@@ -467,3 +575,16 @@
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeletonSweep {
|
||||
to {
|
||||
background-position: -120% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeletonImage,
|
||||
.skeletonCopy span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
+157
-133
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, type CSSProperties } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { BackButton, CharacterAvatar } from "@/app/_components";
|
||||
@@ -16,13 +15,7 @@ import {
|
||||
type PaymentAnalyticsContext,
|
||||
} from "@/lib/analytics";
|
||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
import {
|
||||
buildTipCoffeePath,
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
getTipCoffeeOption,
|
||||
TIP_COFFEE_OPTIONS,
|
||||
type TipCoffeeType,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
useActiveCharacterRoutes,
|
||||
@@ -30,13 +23,11 @@ import {
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import { TipCheckoutButton } from "./tip-checkout-button";
|
||||
import { TipGiftProductSelector } from "./tip-gift-product-selector";
|
||||
import { TipProductImage } from "./tip-product-image";
|
||||
import {
|
||||
TipCoffeeTierSelector,
|
||||
type TipCoffeeTierItem,
|
||||
} from "./tip-coffee-tier-selector";
|
||||
import {
|
||||
findTipCoffeePlan,
|
||||
formatTipPrice,
|
||||
formatGiftPrice,
|
||||
getGiftImageSources,
|
||||
} from "./tip-screen.helpers";
|
||||
import { TipSuccessView } from "./tip-success-view";
|
||||
import { useTipSupportPrompt } from "./use-tip-support-prompt";
|
||||
@@ -48,13 +39,15 @@ const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
||||
};
|
||||
|
||||
export interface TipScreenProps {
|
||||
coffeeType?: TipCoffeeType;
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
shouldResumePendingOrder?: boolean;
|
||||
initialPayChannel?: PayChannel | null;
|
||||
}
|
||||
|
||||
export function TipScreen({
|
||||
coffeeType = DEFAULT_TIP_COFFEE_TYPE,
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
shouldResumePendingOrder = false,
|
||||
initialPayChannel = null,
|
||||
}: TipScreenProps) {
|
||||
@@ -66,63 +59,61 @@ export function TipScreen({
|
||||
countryCode: userState.currentUser?.countryCode,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
});
|
||||
const [selectedCoffeeType, setSelectedCoffeeType] =
|
||||
useState<TipCoffeeType>(coffeeType);
|
||||
const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
|
||||
const returnPath = buildTipCoffeePath(
|
||||
selectedCoffeeType,
|
||||
characterRoutes.tip,
|
||||
);
|
||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||
catalog: "tip",
|
||||
characterId: character.id,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||
paymentType: "tip",
|
||||
shouldResumePendingOrder,
|
||||
});
|
||||
|
||||
const coffeeTiers = useMemo(
|
||||
() =>
|
||||
TIP_COFFEE_OPTIONS.map((option) => ({
|
||||
option,
|
||||
plan: findTipCoffeePlan(payment.plans, option.type),
|
||||
})),
|
||||
[payment.plans],
|
||||
);
|
||||
const coffeePlan =
|
||||
coffeeTiers.find(({ option }) => option.type === selectedCoffeeType)?.plan ??
|
||||
const selectedCategory =
|
||||
payment.giftCategories.find(
|
||||
(category) => category.category === payment.selectedGiftCategory,
|
||||
) ?? null;
|
||||
const visibleProducts = payment.selectedGiftCategory
|
||||
? payment.giftProducts.filter(
|
||||
(product) => product.category === payment.selectedGiftCategory,
|
||||
)
|
||||
: [];
|
||||
const selectedProduct =
|
||||
visibleProducts.find(
|
||||
(product) => product.planId === payment.selectedPlanId,
|
||||
) ?? null;
|
||||
const selectedPlan =
|
||||
payment.plans.find((plan) => plan.planId === payment.selectedPlanId) ??
|
||||
null;
|
||||
const priceLabel = formatTipPrice(coffeePlan, selectedCoffeeType);
|
||||
const availableCoffeePlans = useMemo(
|
||||
() => coffeeTiers.flatMap(({ plan }) => (plan ? [plan] : [])),
|
||||
[coffeeTiers],
|
||||
const returnPath = buildTipGiftPath(
|
||||
{
|
||||
category: payment.selectedGiftCategory,
|
||||
planId: payment.selectedPlanId || null,
|
||||
},
|
||||
characterRoutes.tip,
|
||||
);
|
||||
const tierItems = useMemo<readonly TipCoffeeTierItem[]>(
|
||||
() =>
|
||||
coffeeTiers.map(({ option, plan }) => ({
|
||||
type: option.type,
|
||||
displayName: option.displayName,
|
||||
priceLabel: formatTipPrice(plan, option.type),
|
||||
unavailable:
|
||||
payment.status === "ready" &&
|
||||
!payment.isLoadingPlans &&
|
||||
plan === null,
|
||||
})),
|
||||
[coffeeTiers, payment.isLoadingPlans, payment.status],
|
||||
);
|
||||
usePaymentPlanAnalytics(availableCoffeePlans, TIP_ANALYTICS_CONTEXT);
|
||||
|
||||
usePaymentPlanAnalytics(payment.plans, TIP_ANALYTICS_CONTEXT);
|
||||
|
||||
const isPaymentBusy =
|
||||
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
|
||||
const canCreateOrder =
|
||||
coffeePlan !== null &&
|
||||
payment.selectedPlanId === coffeePlan.planId &&
|
||||
selectedProduct !== null &&
|
||||
selectedPlan !== null &&
|
||||
payment.agreed &&
|
||||
!payment.autoRenew &&
|
||||
!payment.isLoadingPlans &&
|
||||
!isPaymentBusy;
|
||||
const showMissingPlan =
|
||||
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null;
|
||||
const isTierSelectionDisabled =
|
||||
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy;
|
||||
const isSelectionDisabled =
|
||||
!["ready", "failed", "expired"].includes(payment.status) ||
|
||||
payment.isLoadingPlans ||
|
||||
isPaymentBusy;
|
||||
const catalogLoaded =
|
||||
payment.status === "ready" && !payment.isLoadingPlans;
|
||||
const showCatalogError =
|
||||
catalogLoaded && payment.errorMessage !== null && visibleProducts.length === 0;
|
||||
const showEmptyCatalog =
|
||||
catalogLoaded && payment.errorMessage === null && visibleProducts.length === 0;
|
||||
|
||||
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
||||
paymentDispatch({
|
||||
@@ -140,18 +131,7 @@ export function TipScreen({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) {
|
||||
return;
|
||||
}
|
||||
if (!coffeePlan) return;
|
||||
|
||||
if (payment.selectedPlanId !== coffeePlan.planId) {
|
||||
paymentDispatch({
|
||||
type: "PaymentPlanSelected",
|
||||
planId: coffeePlan.planId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!selectedProduct || isPaymentBusy || payment.isLoadingPlans) return;
|
||||
|
||||
if (payment.autoRenew) {
|
||||
paymentDispatch({ type: "PaymentAutoRenewChanged", autoRenew: false });
|
||||
@@ -162,55 +142,62 @@ export function TipScreen({
|
||||
paymentDispatch({ type: "PaymentAgreementChanged", agreed: true });
|
||||
}
|
||||
}, [
|
||||
coffeePlan,
|
||||
isPaymentBusy,
|
||||
payment.agreed,
|
||||
payment.autoRenew,
|
||||
payment.isCreatingOrder,
|
||||
payment.isLoadingPlans,
|
||||
payment.isPollingOrder,
|
||||
payment.selectedPlanId,
|
||||
paymentDispatch,
|
||||
selectedProduct,
|
||||
]);
|
||||
|
||||
const handleOrder = () => {
|
||||
if (!canCreateOrder) return;
|
||||
if (!canCreateOrder || !selectedPlan) return;
|
||||
|
||||
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
|
||||
behaviorAnalytics.planClick(selectedPlan, TIP_ANALYTICS_CONTEXT);
|
||||
paymentDispatch({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
recipientCharacterId: character.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCoffeeTypeChange = (type: TipCoffeeType) => {
|
||||
if (isTierSelectionDisabled) return;
|
||||
const nextPlan = findTipCoffeePlan(payment.plans, type);
|
||||
if (!nextPlan) return;
|
||||
|
||||
setSelectedCoffeeType(type);
|
||||
if (payment.selectedPlanId !== nextPlan.planId) {
|
||||
paymentDispatch({
|
||||
type: "PaymentPlanSelected",
|
||||
planId: nextPlan.planId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPaidState = () => {
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
const handleProductChange = (planId: string) => {
|
||||
if (isSelectionDisabled || planId === payment.selectedPlanId) return;
|
||||
if (!visibleProducts.some((product) => product.planId === planId)) return;
|
||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
||||
};
|
||||
|
||||
if (payment.isPaid) {
|
||||
const successProduct =
|
||||
payment.giftProducts.find(
|
||||
(product) => product.planId === payment.tipMessage?.planId,
|
||||
) ?? selectedProduct;
|
||||
const successCategory =
|
||||
payment.giftCategories.find(
|
||||
(category) => category.category === successProduct?.category,
|
||||
) ?? selectedCategory;
|
||||
|
||||
return (
|
||||
<TipSuccessView
|
||||
characterName={character.displayName}
|
||||
characterAvatar={character.assets.avatar}
|
||||
characterCover={character.assets.cover}
|
||||
coffeeOption={coffeeOption}
|
||||
giftImageSources={getGiftImageSources(
|
||||
successProduct,
|
||||
successCategory,
|
||||
)}
|
||||
giftName={
|
||||
payment.tipMessage?.productName ??
|
||||
successProduct?.planName ??
|
||||
"Your gift"
|
||||
}
|
||||
isMessageLoading={payment.isLoadingTipMessage}
|
||||
message={payment.tipMessage?.message ?? null}
|
||||
messageError={payment.tipMessageError}
|
||||
splashHref={characterRoutes.splash}
|
||||
tipCount={payment.tipCount}
|
||||
thankYouMessage={payment.thankYouMessage}
|
||||
onSendAgain={handleResetPaidState}
|
||||
onRetryMessage={() =>
|
||||
paymentDispatch({ type: "PaymentTipMessageRetryRequested" })
|
||||
}
|
||||
onSendAgain={() => paymentDispatch({ type: "PaymentReset" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -262,57 +249,94 @@ export function TipScreen({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className={styles.productCard} aria-label="Coffee tip product">
|
||||
<div className={styles.coffeeStage}>
|
||||
<Image
|
||||
src={coffeeOption.image.src}
|
||||
alt={`${coffeeOption.displayName} coffee`}
|
||||
width={coffeeOption.image.width}
|
||||
height={coffeeOption.image.height}
|
||||
sizes="(max-width: 380px) 220px, 211px"
|
||||
className={styles.coffeeImage}
|
||||
{payment.isLoadingPlans ? (
|
||||
<section
|
||||
className={`${styles.productCard} ${styles.productSkeleton}`}
|
||||
aria-label="Loading gifts"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className={styles.skeletonImage} />
|
||||
<div className={styles.skeletonCopy}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</section>
|
||||
) : selectedProduct ? (
|
||||
<section className={styles.productCard} aria-label="Gift products">
|
||||
<div className={styles.coffeeStage}>
|
||||
<TipProductImage
|
||||
key={selectedProduct.planId}
|
||||
sources={getGiftImageSources(selectedProduct, selectedCategory)}
|
||||
alt={selectedProduct.planName}
|
||||
className={styles.coffeeImage}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.productCopy}>
|
||||
<span className={styles.productBadge}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{selectedCategory?.name ?? "Gift"}
|
||||
</span>
|
||||
<h2 className={styles.productName}>{selectedProduct.planName}</h2>
|
||||
<p className={styles.productPrice}>
|
||||
{formatGiftPrice(
|
||||
selectedProduct.amountCents,
|
||||
selectedProduct.currency,
|
||||
)}
|
||||
</p>
|
||||
{selectedProduct.description ? (
|
||||
<p className={styles.productDescription}>
|
||||
{selectedProduct.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TipGiftProductSelector
|
||||
disabled={isSelectionDisabled}
|
||||
products={visibleProducts}
|
||||
onChange={handleProductChange}
|
||||
selectedPlanId={payment.selectedPlanId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className={styles.productCopy}>
|
||||
<span className={styles.productBadge}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Coffee Gift
|
||||
</span>
|
||||
<h2 className={styles.productName}>
|
||||
{coffeeOption.displayName}
|
||||
</h2>
|
||||
<p className={styles.productPrice}>{priceLabel}</p>
|
||||
</div>
|
||||
|
||||
<TipCoffeeTierSelector
|
||||
disabled={isTierSelectionDisabled}
|
||||
items={tierItems}
|
||||
onChange={handleCoffeeTypeChange}
|
||||
selectedType={selectedCoffeeType}
|
||||
/>
|
||||
</section>
|
||||
{showCatalogError || showEmptyCatalog ? (
|
||||
<section className={styles.catalogStatus} role="status">
|
||||
<p>
|
||||
{showCatalogError
|
||||
? "We could not load gifts for this character."
|
||||
: "This character does not have any gifts available yet."}
|
||||
</p>
|
||||
{showCatalogError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
paymentDispatch({ type: "PaymentCatalogRetryRequested" })
|
||||
}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={paymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
disabled={isPaymentBusy}
|
||||
disabled={isPaymentBusy || !selectedProduct}
|
||||
caption="GCash by default in the Philippines"
|
||||
className={styles.paymentMethodSlot}
|
||||
analyticsKey="tip.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
/>
|
||||
|
||||
{showMissingPlan ? (
|
||||
<p className={styles.statusMessage} role="alert">
|
||||
Coffee tip is not available yet. Please try again later.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.checkoutSlot}>
|
||||
<TipCheckoutButton
|
||||
coffeeType={selectedCoffeeType}
|
||||
disabled={showMissingPlan || !canCreateOrder}
|
||||
giftCategory={payment.selectedGiftCategory}
|
||||
giftPlanId={payment.selectedPlanId || null}
|
||||
disabled={!canCreateOrder}
|
||||
onOrder={handleOrder}
|
||||
returnPath={returnPath}
|
||||
/>
|
||||
|
||||
@@ -174,6 +174,38 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.messageRetry {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 15px;
|
||||
color: #8a6870;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.messageRetry p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.retryAction {
|
||||
min-height: 36px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid rgba(199, 68, 112, 0.18);
|
||||
border-radius: 999px;
|
||||
background: #fff0f4;
|
||||
color: #bd3d69;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.retryAction:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Coffee, Heart, Sparkles } from "lucide-react";
|
||||
import { Gift, Heart, Sparkles } from "lucide-react";
|
||||
|
||||
import { CharacterAvatar } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import type { TipCoffeeOption } from "@/lib/tip/tip_coffee";
|
||||
|
||||
import { resolveTipSuccessCopy } from "./tip-screen.helpers";
|
||||
import { TipProductImage } from "./tip-product-image";
|
||||
import styles from "./tip-success-view.module.css";
|
||||
|
||||
const GENERIC_TIP_SUCCESS_MESSAGE =
|
||||
"Thank you. Your gift made me smile.";
|
||||
|
||||
export interface TipSuccessViewProps {
|
||||
characterName: string;
|
||||
characterAvatar: string;
|
||||
characterCover: string;
|
||||
coffeeOption: TipCoffeeOption;
|
||||
giftImageSources: readonly string[];
|
||||
giftName: string;
|
||||
isMessageLoading: boolean;
|
||||
message: string | null;
|
||||
messageError: string | null;
|
||||
splashHref: string;
|
||||
tipCount: number | null;
|
||||
thankYouMessage: string | null;
|
||||
onRetryMessage: () => void;
|
||||
onSendAgain: () => void;
|
||||
}
|
||||
|
||||
@@ -27,14 +31,16 @@ export function TipSuccessView({
|
||||
characterName,
|
||||
characterAvatar,
|
||||
characterCover,
|
||||
coffeeOption,
|
||||
giftImageSources,
|
||||
giftName,
|
||||
isMessageLoading,
|
||||
message,
|
||||
messageError,
|
||||
splashHref,
|
||||
tipCount,
|
||||
thankYouMessage,
|
||||
onRetryMessage,
|
||||
onSendAgain,
|
||||
}: TipSuccessViewProps) {
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const copy = resolveTipSuccessCopy(tipCount, thankYouMessage);
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
@@ -76,12 +82,10 @@ export function TipSuccessView({
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.coffeeFrame}>
|
||||
<Image
|
||||
src={coffeeOption.image.src}
|
||||
alt={`${coffeeOption.displayName} coffee`}
|
||||
width={coffeeOption.image.width}
|
||||
height={coffeeOption.image.height}
|
||||
sizes="78px"
|
||||
<TipProductImage
|
||||
key={giftName}
|
||||
sources={giftImageSources}
|
||||
alt={giftName}
|
||||
className={styles.coffeeImage}
|
||||
priority
|
||||
/>
|
||||
@@ -89,8 +93,8 @@ export function TipSuccessView({
|
||||
</div>
|
||||
|
||||
<p className={styles.eyebrow}>
|
||||
<Coffee size={15} aria-hidden="true" />
|
||||
Coffee received
|
||||
<Gift size={15} aria-hidden="true" />
|
||||
Gift received
|
||||
</p>
|
||||
<h1
|
||||
ref={titleRef}
|
||||
@@ -98,13 +102,28 @@ export function TipSuccessView({
|
||||
className={styles.title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{copy.title}
|
||||
You made {characterName} smile.
|
||||
</h1>
|
||||
{copy.body.length > 0 ? (
|
||||
<div className={styles.copy}>
|
||||
{copy.body.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
<div className={styles.copy}>
|
||||
<p>
|
||||
{message ??
|
||||
(isMessageLoading
|
||||
? "Your gift arrived. A thank-you note is on its way."
|
||||
: GENERIC_TIP_SUCCESS_MESSAGE)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{messageError ? (
|
||||
<div className={styles.messageRetry}>
|
||||
<p>We could not load the personal thank-you note.</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryAction}
|
||||
disabled={isMessageLoading}
|
||||
onClick={onRetryMessage}
|
||||
>
|
||||
{isMessageLoading ? "Retrying..." : "Retry message"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -115,7 +134,7 @@ export function TipSuccessView({
|
||||
data-analytics-key="tip.send_again"
|
||||
onClick={onSendAgain}
|
||||
>
|
||||
Send another coffee
|
||||
Send another gift
|
||||
</button>
|
||||
<Link
|
||||
href={splashHref}
|
||||
|
||||
@@ -1,38 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaymentRepository } from "@/data/repositories/payment_repository";
|
||||
import { TipPaymentPlansResponseSchema } from "@/data/schemas/payment";
|
||||
import {
|
||||
GiftProductsResponseSchema,
|
||||
TipMessageResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
import type { PaymentApi } from "@/data/services/api";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
describe("PaymentRepository", () => {
|
||||
it("adapts tip plans without caching them as subscription plans", async () => {
|
||||
const getTipPlans = vi.fn().mockResolvedValue(
|
||||
TipPaymentPlansResponseSchema.parse({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Medium Coffee",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
it("loads a character gift catalog without adapting product metadata", async () => {
|
||||
const catalog = GiftProductsResponseSchema.parse({
|
||||
characterId: "elio",
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Golden Reserve",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_medium",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "Buy Elio a coffee",
|
||||
imageUrl: null,
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const getGiftProducts = vi.fn().mockResolvedValue(catalog);
|
||||
const repository = new PaymentRepository({
|
||||
getTipPlans,
|
||||
getGiftProducts,
|
||||
} as unknown as PaymentApi);
|
||||
|
||||
const result = await repository.getTipPlans();
|
||||
const result = await repository.getGiftProducts("elio");
|
||||
|
||||
expect(getTipPlans).toHaveBeenCalledOnce();
|
||||
expect(Result.isOk(result) && result.data.plans[0]).toMatchObject({
|
||||
expect(getGiftProducts).toHaveBeenCalledWith("elio");
|
||||
expect(Result.isOk(result) && result.data).toBe(catalog);
|
||||
});
|
||||
|
||||
it("validates and forwards a Tip message order id", async () => {
|
||||
const tipMessage = TipMessageResponseSchema.parse({
|
||||
orderId: "pay_xxx",
|
||||
characterId: "elio",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Medium Coffee",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
isFirstRechargeOffer: false,
|
||||
productName: "Golden Reserve",
|
||||
tipCount: 1,
|
||||
poolIndex: 1,
|
||||
message: "Thank you.",
|
||||
});
|
||||
const getTipMessage = vi.fn().mockResolvedValue(tipMessage);
|
||||
const repository = new PaymentRepository({
|
||||
getTipMessage,
|
||||
} as unknown as PaymentApi);
|
||||
|
||||
const result = await repository.getTipMessage("pay_xxx");
|
||||
|
||||
expect(getTipMessage).toHaveBeenCalledWith({ orderId: "pay_xxx" });
|
||||
expect(Result.isOk(result) && result.data.message).toBe("Thank you.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
*/
|
||||
import type {
|
||||
CreatePaymentOrderResponse,
|
||||
GiftProductsResponse,
|
||||
PayChannel,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentPlansResponse,
|
||||
TipMessageResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
@@ -16,8 +18,8 @@ export interface IPaymentRepository {
|
||||
/** 获取本地缓存套餐列表。 */
|
||||
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
|
||||
|
||||
/** 获取咖啡打赏套餐列表。 */
|
||||
getTipPlans(): Promise<Result<PaymentPlansResponse>>;
|
||||
/** 获取当前角色的完整礼物目录。 */
|
||||
getGiftProducts(characterId: string): Promise<Result<GiftProductsResponse>>;
|
||||
|
||||
/** 清除本地缓存套餐列表。 */
|
||||
clearCachedPlans(): Promise<Result<void>>;
|
||||
@@ -32,4 +34,7 @@ export interface IPaymentRepository {
|
||||
|
||||
/** 查询支付订单状态。 */
|
||||
getOrderStatus(orderId: string): Promise<Result<PaymentOrderStatusResponse>>;
|
||||
|
||||
/** 获取已支付礼物订单的角色感谢文案。 */
|
||||
getTipMessage(orderId: string): Promise<Result<TipMessageResponse>>;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import type { IPaymentRepository } from "@/data/repositories/interfaces";
|
||||
import {
|
||||
CreatePaymentOrderRequestSchema,
|
||||
CreatePaymentOrderResponse,
|
||||
GiftProductsResponse,
|
||||
PayChannel,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansResponseSchema,
|
||||
TipMessageRequestSchema,
|
||||
TipMessageResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
import { PaymentApi, paymentApi } from "@/data/services/api";
|
||||
import { PaymentPlansStorage } from "@/data/storage/payment";
|
||||
@@ -38,25 +41,11 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取咖啡打赏套餐列表,不写入通用套餐缓存。 */
|
||||
async getTipPlans(): Promise<Result<PaymentPlansResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.getTipPlans();
|
||||
return PaymentPlansResponseSchema.parse({
|
||||
plans: response.plans.map((plan) => ({
|
||||
...plan,
|
||||
orderType: "tip",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
creditBalance: 0,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: null,
|
||||
promotionType: null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
/** 获取当前角色的完整礼物目录,不写入订阅套餐缓存。 */
|
||||
async getGiftProducts(
|
||||
characterId: string,
|
||||
): Promise<Result<GiftProductsResponse>> {
|
||||
return Result.wrap(() => this.api.getGiftProducts(characterId));
|
||||
}
|
||||
|
||||
/** 清除本地缓存套餐列表。 */
|
||||
@@ -89,6 +78,12 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
): Promise<Result<PaymentOrderStatusResponse>> {
|
||||
return Result.wrap(() => this.api.getOrderStatus(orderId));
|
||||
}
|
||||
|
||||
/** 获取已支付礼物订单的角色感谢文案。 */
|
||||
async getTipMessage(orderId: string): Promise<Result<TipMessageResponse>> {
|
||||
const request = TipMessageRequestSchema.parse({ orderId });
|
||||
return Result.wrap(() => this.api.getTipMessage(request));
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局懒单例。 */
|
||||
|
||||
@@ -2,21 +2,21 @@ import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
GiftProductsResponseSchema,
|
||||
PaymentOrderStatusResponseSchema,
|
||||
PaymentPlanSchema,
|
||||
PaymentPlansResponseSchema,
|
||||
TipPaymentPlansResponseSchema,
|
||||
TipMessageResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
describe("PaymentOrderStatusResponse", () => {
|
||||
it("parses paid Tip success metadata", () => {
|
||||
it("parses the paid gift order shape", () => {
|
||||
const response = PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "tip_order_123",
|
||||
status: "paid",
|
||||
orderType: "tip",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
tipCount: 2,
|
||||
thankYouMessage: " You made my day. ",
|
||||
creditsAdded: 0,
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
@@ -24,37 +24,20 @@ describe("PaymentOrderStatusResponse", () => {
|
||||
status: "paid",
|
||||
orderType: "tip",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
tipCount: 2,
|
||||
thankYouMessage: "You made my day.",
|
||||
creditsAdded: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("degrades missing or invalid optional Tip metadata to null", () => {
|
||||
it("accepts expired status and a nullable plan id", () => {
|
||||
expect(
|
||||
PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "pay_order_456",
|
||||
status: "pending",
|
||||
orderType: "vip_monthly",
|
||||
planId: "vip_monthly",
|
||||
}),
|
||||
).toMatchObject({
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "tip_order_invalid",
|
||||
status: "paid",
|
||||
status: "expired",
|
||||
orderType: "tip",
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
tipCount: 0,
|
||||
thankYouMessage: " ",
|
||||
planId: null,
|
||||
creditsAdded: 0,
|
||||
}),
|
||||
).toMatchObject({
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
).toMatchObject({ status: "expired", planId: null, creditsAdded: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,34 +192,58 @@ describe("PaymentPlansResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TipPaymentPlansResponse", () => {
|
||||
it("keeps only fields used by the tip payment flow", () => {
|
||||
const response = TipPaymentPlansResponseSchema.parse({
|
||||
describe("GiftProductsResponse", () => {
|
||||
it("parses and freezes categories and complete gift products", () => {
|
||||
const response = GiftProductsResponseSchema.parse({
|
||||
characterId: "elio",
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
planName: "Velvet Espresso",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_small",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "Buy Elio a small coffee",
|
||||
imageUrl: null,
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.categories[0]?.category).toBe("coffee");
|
||||
expect(response.plans[0]?.description).toBe("Buy Elio a small coffee");
|
||||
expect(Object.isFrozen(response)).toBe(true);
|
||||
expect(Object.isFrozen(response.categories)).toBe(true);
|
||||
expect(Object.isFrozen(response.plans[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TipMessageResponse", () => {
|
||||
it("parses the complete stable backend message", () => {
|
||||
const response = TipMessageResponseSchema.parse({
|
||||
orderId: "pay_xxx",
|
||||
characterId: "elio",
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
productName: "Velvet Espresso",
|
||||
tipCount: 2,
|
||||
poolIndex: 37,
|
||||
message: "You have a knack for making me smile.",
|
||||
});
|
||||
|
||||
expect(response.message).toBe("You have a knack for making me smile.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const GiftCategorySchema = z
|
||||
.object({
|
||||
category: z.string().min(1),
|
||||
name: z.string(),
|
||||
productCount: z.number().int().nonnegative(),
|
||||
imageUrl: z.string().nullable(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type GiftCategoryInput = z.input<typeof GiftCategorySchema>;
|
||||
export type GiftCategoryData = z.output<typeof GiftCategorySchema>;
|
||||
export type GiftCategory = GiftCategoryData;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOrNull } from "../nullable-defaults";
|
||||
|
||||
export const GiftProductSchema = z
|
||||
.object({
|
||||
planId: z.string().min(1),
|
||||
planName: z.string(),
|
||||
orderType: z.literal("tip"),
|
||||
tipType: z.string(),
|
||||
category: z.string().min(1),
|
||||
characterId: z.string().min(1),
|
||||
description: z.string(),
|
||||
imageUrl: z.string().nullable(),
|
||||
amountCents: z.number().int().nonnegative(),
|
||||
currency: z.string(),
|
||||
autoRenew: z.literal(false),
|
||||
isFirstRechargeOffer: z.literal(false),
|
||||
firstRechargeDiscountPercent: z.number().int(),
|
||||
promotionType: stringOrNull,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type GiftProductInput = z.input<typeof GiftProductSchema>;
|
||||
export type GiftProductData = z.output<typeof GiftProductSchema>;
|
||||
export type GiftProduct = GiftProductData;
|
||||
@@ -3,9 +3,12 @@
|
||||
*/
|
||||
|
||||
export * from "./payment_plan";
|
||||
export * from "./tip_payment_plan";
|
||||
export * from "./gift_category";
|
||||
export * from "./gift_product";
|
||||
export * from "./request/create_payment_order_request";
|
||||
export * from "./request/tip_message_request";
|
||||
export * from "./response/create_payment_order_response";
|
||||
export * from "./response/gift_products_response";
|
||||
export * from "./response/payment_order_status_response";
|
||||
export * from "./response/payment_plans_response";
|
||||
export * from "./response/tip_payment_plans_response";
|
||||
export * from "./response/tip_message_response";
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
*/
|
||||
|
||||
export * from "./create_payment_order_request";
|
||||
export * from "./tip_message_request";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TipMessageRequestSchema = z
|
||||
.object({
|
||||
orderId: z.string().min(1),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type TipMessageRequestInput = z.input<typeof TipMessageRequestSchema>;
|
||||
export type TipMessageRequestData = z.output<typeof TipMessageRequestSchema>;
|
||||
export type TipMessageRequest = TipMessageRequestData;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty, stringOrNull } from "../../nullable-defaults";
|
||||
import { GiftCategorySchema } from "../gift_category";
|
||||
import { GiftProductSchema } from "../gift_product";
|
||||
|
||||
export const GiftProductsResponseSchema = z
|
||||
.object({
|
||||
characterId: stringOrNull,
|
||||
categories: arrayOrEmpty(GiftCategorySchema),
|
||||
plans: arrayOrEmpty(GiftProductSchema),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type GiftProductsResponseInput = z.input<
|
||||
typeof GiftProductsResponseSchema
|
||||
>;
|
||||
export type GiftProductsResponseData = z.output<
|
||||
typeof GiftProductsResponseSchema
|
||||
>;
|
||||
export type GiftProductsResponse = GiftProductsResponseData;
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export * from "./create_payment_order_response";
|
||||
export * from "./gift_products_response";
|
||||
export * from "./payment_order_status_response";
|
||||
export * from "./payment_plans_response";
|
||||
export * from "./tip_payment_plans_response";
|
||||
export * from "./tip_message_response";
|
||||
|
||||
@@ -3,33 +3,20 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]);
|
||||
|
||||
const tipCountOrNull = z.preprocess(
|
||||
(value) =>
|
||||
typeof value === "number" && Number.isInteger(value) && value > 0
|
||||
? value
|
||||
: null,
|
||||
z.number().int().positive().nullable(),
|
||||
);
|
||||
|
||||
const thankYouMessageOrNull = z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value !== "string") return null;
|
||||
const message = value.trim();
|
||||
return message.length > 0 ? message : null;
|
||||
},
|
||||
z.string().nullable(),
|
||||
);
|
||||
export const PaymentOrderStatusSchema = z.enum([
|
||||
"pending",
|
||||
"paid",
|
||||
"failed",
|
||||
"expired",
|
||||
]);
|
||||
|
||||
export const PaymentOrderStatusResponseSchema = z
|
||||
.object({
|
||||
orderId: z.string(),
|
||||
status: PaymentOrderStatusSchema,
|
||||
orderType: z.string(),
|
||||
planId: z.string(),
|
||||
tipCount: tipCountOrNull,
|
||||
thankYouMessage: thankYouMessageOrNull,
|
||||
planId: z.string().nullable(),
|
||||
creditsAdded: z.number().int(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TipMessageResponseSchema = z
|
||||
.object({
|
||||
orderId: z.string().min(1),
|
||||
characterId: z.string().min(1),
|
||||
planId: z.string().min(1),
|
||||
productName: z.string(),
|
||||
tipCount: z.number().int().positive(),
|
||||
poolIndex: z.number().int().min(0).max(99),
|
||||
message: z.string().min(1),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type TipMessageResponseInput = z.input<typeof TipMessageResponseSchema>;
|
||||
export type TipMessageResponseData = z.output<typeof TipMessageResponseSchema>;
|
||||
export type TipMessageResponse = TipMessageResponseData;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty } from "../../nullable-defaults";
|
||||
import { TipPaymentPlanSchema } from "../tip_payment_plan";
|
||||
|
||||
export const TipPaymentPlansResponseSchema = z
|
||||
.object({
|
||||
plans: arrayOrEmpty(TipPaymentPlanSchema),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type TipPaymentPlansResponseInput = z.input<
|
||||
typeof TipPaymentPlansResponseSchema
|
||||
>;
|
||||
export type TipPaymentPlansResponseData = z.output<
|
||||
typeof TipPaymentPlansResponseSchema
|
||||
>;
|
||||
|
||||
export type TipPaymentPlansResponse = TipPaymentPlansResponseData;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TipPaymentPlanSchema = z
|
||||
.object({
|
||||
planId: z.string(),
|
||||
planName: z.string(),
|
||||
amountCents: z.number(),
|
||||
currency: z.string(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>;
|
||||
export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>;
|
||||
|
||||
export type TipPaymentPlan = TipPaymentPlanData;
|
||||
@@ -13,39 +13,72 @@ describe("PaymentApi", () => {
|
||||
httpClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("loads public coffee tip plans from the dedicated endpoint", async () => {
|
||||
it("loads the complete public gift catalog for a character", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
characterId: "elio",
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
planName: "Large Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_large",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "Buy Elio a large coffee",
|
||||
imageUrl: null,
|
||||
amountCents: 1999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await new PaymentApi().getTipPlans();
|
||||
const response = await new PaymentApi().getGiftProducts("elio");
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans");
|
||||
expect(response).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
planName: "Large Coffee",
|
||||
amountCents: 1999,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
expect(httpClientMock).toHaveBeenCalledWith(
|
||||
"/api/payment/gift-products",
|
||||
{ query: { characterId: "elio" } },
|
||||
);
|
||||
expect(response.categories[0]?.category).toBe("coffee");
|
||||
expect(response.plans[0]?.planId).toBe("tip_coffee_usd_19_99");
|
||||
});
|
||||
|
||||
it("posts the paid order id when loading the Tip message", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
orderId: "pay_xxx",
|
||||
characterId: "elio",
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
productName: "Large Coffee",
|
||||
tipCount: 1,
|
||||
poolIndex: 7,
|
||||
message: "Thank you for the thoughtful gift.",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await new PaymentApi().getTipMessage({
|
||||
orderId: "pay_xxx",
|
||||
});
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-message", {
|
||||
method: "POST",
|
||||
body: { orderId: "pay_xxx" },
|
||||
});
|
||||
expect(response.message).toBe("Thank you for the thoughtful gift.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"paymentCreateOrder": { "method": "post", "path": "/api/payment/create-order" },
|
||||
"paymentOrderStatus": { "method": "get", "path": "/api/payment/order-status" },
|
||||
"paymentPlans": { "method": "get", "path": "/api/payment/plans" },
|
||||
"paymentTipPlans": { "method": "get", "path": "/api/payment/tip-plans" },
|
||||
"paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" },
|
||||
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
|
||||
"chatSend": { "method": "post", "path": "/api/chat/send" },
|
||||
"chatHistory": { "method": "get", "path": "/api/chat/history" },
|
||||
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
|
||||
|
||||
@@ -60,8 +60,11 @@ export class ApiPath {
|
||||
/** 获取商品套餐列表 */
|
||||
static readonly paymentPlans = apiContract.paymentPlans.path;
|
||||
|
||||
/** 获取咖啡打赏套餐列表 */
|
||||
static readonly paymentTipPlans = apiContract.paymentTipPlans.path;
|
||||
/** 获取角色的完整礼物目录 */
|
||||
static readonly paymentGiftProducts = apiContract.paymentGiftProducts.path;
|
||||
|
||||
/** 获取已支付礼物订单的角色感谢文案 */
|
||||
static readonly paymentTipMessage = apiContract.paymentTipMessage.path;
|
||||
|
||||
// ============ 聊天相关 ============
|
||||
/** 发送消息 */
|
||||
|
||||
@@ -7,12 +7,15 @@ import {
|
||||
CreatePaymentOrderRequest,
|
||||
CreatePaymentOrderResponse,
|
||||
CreatePaymentOrderResponseSchema,
|
||||
GiftProductsResponse,
|
||||
GiftProductsResponseSchema,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentOrderStatusResponseSchema,
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansResponseSchema,
|
||||
TipPaymentPlansResponse,
|
||||
TipPaymentPlansResponseSchema,
|
||||
TipMessageRequest,
|
||||
TipMessageResponse,
|
||||
TipMessageResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
@@ -28,10 +31,13 @@ export class PaymentApi {
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取咖啡打赏套餐列表。 */
|
||||
async getTipPlans(): Promise<TipPaymentPlansResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentTipPlans);
|
||||
return TipPaymentPlansResponseSchema.parse(
|
||||
/** 一次获取当前角色的完整礼物目录。 */
|
||||
async getGiftProducts(characterId: string): Promise<GiftProductsResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.paymentGiftProducts,
|
||||
{ query: { characterId } },
|
||||
);
|
||||
return GiftProductsResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
@@ -64,6 +70,20 @@ export class PaymentApi {
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取已支付礼物订单的稳定感谢文案。 */
|
||||
async getTipMessage(body: TipMessageRequest): Promise<TipMessageResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.paymentTipMessage,
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
},
|
||||
);
|
||||
return TipMessageResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,15 +7,18 @@ import { SpAsyncUtil } from "@/utils/storage";
|
||||
|
||||
import { StorageKeys } from "../storage_keys";
|
||||
|
||||
const PendingPaymentOrderSchema = z.object({
|
||||
orderId: z.string().min(1),
|
||||
payChannel: z.literal("ezpay"),
|
||||
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
||||
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
|
||||
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
|
||||
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
const PendingPaymentOrderSchema = z
|
||||
.object({
|
||||
orderId: z.string().min(1),
|
||||
payChannel: z.literal("ezpay"),
|
||||
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
||||
giftCategory: z.string().min(1).nullable().default(null),
|
||||
giftPlanId: z.string().min(1).nullable().default(null),
|
||||
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
|
||||
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
||||
createdAt: z.number(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type PendingPaymentOrder = z.output<typeof PendingPaymentOrderSchema>;
|
||||
|
||||
|
||||
@@ -88,26 +88,27 @@ describe("pending payment order helpers", () => {
|
||||
await clearPendingPaymentOrder();
|
||||
});
|
||||
|
||||
it("routes tip payments back to the tip page", () => {
|
||||
it("routes tip payments back with dynamic category and plan id", () => {
|
||||
expect(
|
||||
buildPendingPaymentSubscriptionUrl({
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: "tip",
|
||||
tipCoffeeType: "large",
|
||||
giftCategory: "coffee",
|
||||
giftPlanId: "tip_coffee_usd_19_99",
|
||||
}),
|
||||
).toBe(
|
||||
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large",
|
||||
"/characters/elio/tip?category=coffee&planId=tip_coffee_usd_19_99&payChannel=ezpay&paymentReturn=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults legacy tip payment returns to medium coffee", () => {
|
||||
it("lets legacy tip records fall back to the first backend product", () => {
|
||||
expect(
|
||||
buildPendingPaymentSubscriptionUrl({
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: "tip",
|
||||
}),
|
||||
).toBe(
|
||||
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium",
|
||||
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -116,11 +117,12 @@ describe("pending payment order helpers", () => {
|
||||
buildPendingPaymentSubscriptionUrl({
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: "tip",
|
||||
tipCoffeeType: "small",
|
||||
giftCategory: "coffee",
|
||||
giftPlanId: "tip_coffee_usd_4_99",
|
||||
characterSlug: "nayeli",
|
||||
}),
|
||||
).toBe(
|
||||
"/characters/nayeli/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small",
|
||||
"/characters/nayeli/tip?category=coffee&planId=tip_coffee_usd_4_99&payChannel=ezpay&paymentReturn=1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
savePendingEzpayOrder,
|
||||
type PendingPaymentReturnTo,
|
||||
type PendingPaymentSubscriptionType,
|
||||
type PendingPaymentTipCoffeeType,
|
||||
} from "./pending_payment_order";
|
||||
|
||||
const log = new Logger("LibPaymentPaymentLaunch");
|
||||
@@ -63,7 +62,8 @@ export interface LaunchEzpayRedirectInput {
|
||||
orderId: string | null;
|
||||
paymentUrl: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
giftCategory?: string | null;
|
||||
giftPlanId?: string | null;
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
characterSlug?: string;
|
||||
onOpened?: () => void;
|
||||
@@ -74,7 +74,8 @@ export async function launchEzpayRedirect({
|
||||
orderId,
|
||||
paymentUrl,
|
||||
subscriptionType,
|
||||
tipCoffeeType,
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
returnTo,
|
||||
characterSlug,
|
||||
onOpened,
|
||||
@@ -101,7 +102,8 @@ export async function launchEzpayRedirect({
|
||||
const saveResult = await savePendingEzpayOrder({
|
||||
orderId,
|
||||
subscriptionType,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
giftCategory,
|
||||
giftPlanId,
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
...(characterSlug ? { characterSlug } : {}),
|
||||
});
|
||||
|
||||
@@ -9,23 +9,18 @@ import {
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||
import {
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
TIP_COFFEE_TYPE_PARAM,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export type PendingPaymentSubscriptionType =
|
||||
PendingPaymentOrder["subscriptionType"];
|
||||
export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"];
|
||||
export type PendingPaymentTipCoffeeType = NonNullable<
|
||||
PendingPaymentOrder["tipCoffeeType"]
|
||||
>;
|
||||
|
||||
export function savePendingEzpayOrder(input: {
|
||||
orderId: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
giftCategory?: string | null;
|
||||
giftPlanId?: string | null;
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
characterSlug?: string;
|
||||
createdAt?: number;
|
||||
@@ -34,7 +29,8 @@ export function savePendingEzpayOrder(input: {
|
||||
orderId: input.orderId,
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: input.subscriptionType,
|
||||
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}),
|
||||
giftCategory: input.giftCategory ?? null,
|
||||
giftPlanId: input.giftPlanId ?? null,
|
||||
...(input.returnTo ? { returnTo: input.returnTo } : {}),
|
||||
...(input.characterSlug ? { characterSlug: input.characterSlug } : {}),
|
||||
createdAt: input.createdAt ?? Date.now(),
|
||||
@@ -63,22 +59,24 @@ export function buildPendingPaymentSubscriptionUrl(
|
||||
| "payChannel"
|
||||
| "returnTo"
|
||||
| "subscriptionType"
|
||||
| "tipCoffeeType"
|
||||
| "characterSlug"
|
||||
>,
|
||||
> &
|
||||
Partial<Pick<PendingPaymentOrder, "giftCategory" | "giftPlanId">>,
|
||||
): string {
|
||||
const characterSlug =
|
||||
getCharacterBySlug(order.characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
|
||||
const characterRoutes = getCharacterRoutes(characterSlug);
|
||||
|
||||
if (order.subscriptionType === "tip") {
|
||||
const params = new URLSearchParams({
|
||||
payChannel: order.payChannel,
|
||||
paymentReturn: "1",
|
||||
[TIP_COFFEE_TYPE_PARAM]:
|
||||
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE,
|
||||
});
|
||||
return `${characterRoutes.tip}?${params.toString()}`;
|
||||
const path = buildTipGiftPath(
|
||||
{
|
||||
category: order.giftCategory ?? null,
|
||||
planId: order.giftPlanId ?? null,
|
||||
},
|
||||
characterRoutes.tip,
|
||||
);
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
return `${path}${separator}payChannel=${order.payChannel}&paymentReturn=1`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildTipCoffeePath,
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
getTipCoffeeOption,
|
||||
resolveTipCoffeeType,
|
||||
TIP_COFFEE_OPTIONS,
|
||||
} from "../tip_coffee";
|
||||
|
||||
describe("tip coffee configuration", () => {
|
||||
it("resolves supported types case-insensitively", () => {
|
||||
expect(resolveTipCoffeeType(" Small ")).toBe("small");
|
||||
expect(resolveTipCoffeeType("MEDIUM")).toBe("medium");
|
||||
expect(resolveTipCoffeeType("large")).toBe("large");
|
||||
});
|
||||
|
||||
it("rejects missing and unsupported types", () => {
|
||||
expect(resolveTipCoffeeType(null)).toBeNull();
|
||||
expect(resolveTipCoffeeType("espresso")).toBeNull();
|
||||
});
|
||||
|
||||
it("provides the configured plan and fallback price for each type", () => {
|
||||
expect(getTipCoffeeOption("small")).toMatchObject({
|
||||
amountCents: 499,
|
||||
displayName: "Velvet Espresso",
|
||||
image: { src: "/images/tip/small.jpg", width: 736, height: 736 },
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
});
|
||||
expect(getTipCoffeeOption("medium")).toMatchObject({
|
||||
amountCents: 999,
|
||||
displayName: "Gilded Heart",
|
||||
image: { src: "/images/tip/medium.png", width: 1024, height: 1024 },
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
});
|
||||
expect(getTipCoffeeOption("large")).toMatchObject({
|
||||
amountCents: 1999,
|
||||
displayName: "Crown Blossom",
|
||||
image: { src: "/images/tip/large.png", width: 1024, height: 1024 },
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists all tiers in display order and defaults to medium", () => {
|
||||
expect(TIP_COFFEE_OPTIONS.map(({ type }) => type)).toEqual([
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
]);
|
||||
expect(DEFAULT_TIP_COFFEE_TYPE).toBe("medium");
|
||||
});
|
||||
|
||||
it("builds canonical tip paths", () => {
|
||||
expect(buildTipCoffeePath("small")).toBe("/tip?coffee_type=small");
|
||||
expect(buildTipCoffeePath("medium")).toBe("/tip?coffee_type=medium");
|
||||
expect(buildTipCoffeePath("large")).toBe("/tip?coffee_type=large");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildTipGiftPath,
|
||||
normalizeTipGiftParam,
|
||||
} from "../tip_gift";
|
||||
|
||||
describe("tip gift navigation", () => {
|
||||
it("normalizes optional gift query values", () => {
|
||||
expect(normalizeTipGiftParam(" coffee ")).toBe("coffee");
|
||||
expect(normalizeTipGiftParam(" ")).toBeNull();
|
||||
expect(normalizeTipGiftParam(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds canonical category and plan return paths", () => {
|
||||
expect(
|
||||
buildTipGiftPath({ category: "coffee", planId: "gift_1" }),
|
||||
).toBe("/tip?category=coffee&planId=gift_1");
|
||||
});
|
||||
|
||||
it("omits missing selection values without restoring coffee_type", () => {
|
||||
expect(buildTipGiftPath({ category: null, planId: null })).toBe("/tip");
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export const TIP_COFFEE_TYPE_PARAM = "coffee_type";
|
||||
export const DEFAULT_TIP_COFFEE_TYPE = "medium";
|
||||
|
||||
export type TipCoffeeType = "small" | "medium" | "large";
|
||||
|
||||
export interface TipCoffeeOption {
|
||||
readonly type: TipCoffeeType;
|
||||
readonly amountCents: number;
|
||||
readonly displayName: string;
|
||||
readonly image: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
readonly planId: string;
|
||||
}
|
||||
|
||||
const TIP_COFFEE_OPTION_BY_TYPE: Record<TipCoffeeType, TipCoffeeOption> = {
|
||||
small: {
|
||||
type: "small",
|
||||
amountCents: 499,
|
||||
displayName: "Velvet Espresso",
|
||||
image: {
|
||||
src: "/images/tip/small.jpg",
|
||||
width: 736,
|
||||
height: 736,
|
||||
},
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
},
|
||||
medium: {
|
||||
type: "medium",
|
||||
amountCents: 999,
|
||||
displayName: "Gilded Heart",
|
||||
image: {
|
||||
src: "/images/tip/medium.png",
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
},
|
||||
large: {
|
||||
type: "large",
|
||||
amountCents: 1999,
|
||||
displayName: "Crown Blossom",
|
||||
image: {
|
||||
src: "/images/tip/large.png",
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
},
|
||||
};
|
||||
|
||||
export const TIP_COFFEE_OPTIONS: readonly TipCoffeeOption[] = [
|
||||
TIP_COFFEE_OPTION_BY_TYPE.small,
|
||||
TIP_COFFEE_OPTION_BY_TYPE.medium,
|
||||
TIP_COFFEE_OPTION_BY_TYPE.large,
|
||||
];
|
||||
|
||||
export function resolveTipCoffeeType(
|
||||
value: string | null | undefined,
|
||||
): TipCoffeeType | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "small" ||
|
||||
normalized === "medium" ||
|
||||
normalized === "large"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption {
|
||||
return TIP_COFFEE_OPTION_BY_TYPE[type];
|
||||
}
|
||||
|
||||
export function buildTipCoffeePath(
|
||||
type: TipCoffeeType,
|
||||
basePath: string = ROUTES.tip,
|
||||
): string {
|
||||
const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type });
|
||||
return `${basePath}?${params.toString()}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export const TIP_GIFT_CATEGORY_PARAM = "category";
|
||||
export const TIP_GIFT_PLAN_ID_PARAM = "planId";
|
||||
|
||||
export interface TipGiftSelection {
|
||||
category: string | null;
|
||||
planId: string | null;
|
||||
}
|
||||
|
||||
export function normalizeTipGiftParam(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export function buildTipGiftPath(
|
||||
selection: TipGiftSelection,
|
||||
basePath: string = ROUTES.tip,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (selection.category) {
|
||||
params.set(TIP_GIFT_CATEGORY_PARAM, selection.category);
|
||||
}
|
||||
if (selection.planId) {
|
||||
params.set(TIP_GIFT_PLAN_ID_PARAM, selection.planId);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `${basePath}?${query}` : basePath;
|
||||
}
|
||||
@@ -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