feat(tip): support dynamic gift products
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user