feat(tip): support dynamic gift products

This commit is contained in:
2026-07-21 13:19:45 +08:00
parent 55cb98ed14
commit 37ff69020b
62 changed files with 2325 additions and 1085 deletions
@@ -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,