From c187f0b817fd93fe1f31ede3df172ef4788f7ab0 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 20 Jul 2026 18:36:44 +0800 Subject: [PATCH] fix(tip): allow checkout for all auth states --- .../__tests__/tip-screen.checkout.test.tsx | 191 ++++++++++++++++++ .../tip/__tests__/tip-screen.helpers.test.ts | 7 - src/app/tip/tip-checkout-button.tsx | 5 +- src/app/tip/tip-screen.helpers.ts | 5 - src/app/tip/tip-screen.tsx | 24 +-- 5 files changed, 196 insertions(+), 36 deletions(-) create mode 100644 src/app/tip/__tests__/tip-screen.checkout.test.tsx diff --git a/src/app/tip/__tests__/tip-screen.checkout.test.tsx b/src/app/tip/__tests__/tip-screen.checkout.test.tsx new file mode 100644 index 00000000..792ba704 --- /dev/null +++ b/src/app/tip/__tests__/tip-screen.checkout.test.tsx @@ -0,0 +1,191 @@ +/* @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + PaymentPlanSchema, + type PaymentPlan, +} from "@/data/schemas/payment"; +import type { PaymentContextState } from "@/stores/payment/payment-context"; + +const mocks = vi.hoisted(() => ({ + payment: null as unknown as PaymentContextState, + paymentDispatch: vi.fn(), + planClick: vi.fn(), +})); + +vi.mock("@/app/_components", () => ({ + BackButton: () => null, + CharacterAvatar: () => null, +})); +vi.mock("@/app/_components/core", () => ({ + MobileShell: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@/app/_components/payment/payment-method-selector", () => ({ + PaymentMethodSelector: () => null, +})); +vi.mock("@/app/_hooks/use-payment-method-selection", () => ({ + usePaymentMethodSelection: () => undefined, +})); +vi.mock("@/app/_hooks/use-payment-plan-analytics", () => ({ + usePaymentPlanAnalytics: () => undefined, +})); +vi.mock("@/app/_hooks/use-payment-route-flow", () => ({ + usePaymentRouteFlow: () => ({ + payment: mocks.payment, + paymentDispatch: mocks.paymentDispatch, + }), +})); +vi.mock("@/lib/analytics", () => ({ + behaviorAnalytics: { planClick: mocks.planClick }, +})); +vi.mock("@/providers/character-provider", () => ({ + useActiveCharacter: () => ({ + id: "maya-tan", + displayName: "Maya Tan", + assets: { + avatar: "/images/avatar/maya.png", + cover: "/images/cover/maya.png", + }, + copy: { + tipHeader: "Tip Maya", + tipTitle: "Buy Maya a coffee", + }, + }), + useActiveCharacterRoutes: () => ({ + splash: "/characters/maya/splash", + tip: "/characters/maya/tip", + }), +})); +vi.mock("@/stores/user/user-context", () => ({ + useUserState: () => ({ currentUser: null }), +})); +vi.mock("../tip-checkout-button", () => ({ + TipCheckoutButton: ({ + disabled, + onOrder, + }: { + disabled?: boolean; + onOrder: () => void; + }) => ( + + ), +})); +vi.mock("../tip-coffee-tier-selector", () => ({ + TipCoffeeTierSelector: () => null, +})); + +import { TipScreen } from "../tip-screen"; + +const mediumPlan: PaymentPlan = PaymentPlanSchema.parse({ + planId: "tip_coffee_usd_9_99", + planName: "Gilded Heart", + orderType: "tip", + vipDays: null, + dolAmount: null, + creditBalance: 0, + amountCents: 999, + originalAmountCents: null, + dailyPriceCents: null, + currency: "USD", + isFirstRechargeOffer: false, + mostPopular: false, + firstRechargeDiscountPercent: null, + promotionType: null, +}); + +describe("TipScreen checkout", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + mocks.payment = makePaymentState(); + mocks.paymentDispatch.mockReset(); + mocks.planClick.mockReset(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("creates a character-attributed order without an AuthProvider", () => { + renderScreen(); + const checkout = getCheckoutButton(); + + expect(checkout.disabled).toBe(false); + act(() => checkout.click()); + + expect(mocks.planClick).toHaveBeenCalledWith( + mediumPlan, + expect.objectContaining({ entryPoint: "tip_page" }), + ); + expect(mocks.paymentDispatch).toHaveBeenCalledWith({ + type: "PaymentCreateOrderSubmitted", + recipientCharacterId: "maya-tan", + }); + }); + + it.each([ + ["plans are loading", { isLoadingPlans: true }], + ["the selected plan is missing", { plans: [], selectedPlanId: "" }], + ["an order is being created", { isCreatingOrder: true }], + ["an order is being polled", { isPollingOrder: true }], + ["the order is paid", { isPaid: true }], + ] as const)("disables checkout when %s", (_label, overrides) => { + mocks.payment = makePaymentState(overrides); + renderScreen(); + + expect(getCheckoutButton().disabled).toBe(true); + }); + + function renderScreen(): void { + act(() => root.render()); + } + + function getCheckoutButton(): HTMLButtonElement { + const button = container.querySelector( + '[data-testid="tip-checkout"]', + ); + if (!button) throw new Error("Missing Tip checkout button"); + return button; + } +}); + +function makePaymentState( + overrides: Partial = {}, +): PaymentContextState { + return { + status: "ready", + plans: [mediumPlan], + isFirstRecharge: false, + selectedPlanId: mediumPlan.planId, + payChannel: "stripe", + autoRenew: false, + agreed: true, + currentOrderId: null, + payParams: null, + orderStatus: null, + errorMessage: null, + launchNonce: 0, + isLoadingPlans: false, + isCreatingOrder: false, + isPollingOrder: false, + isPaid: false, + ...overrides, + }; +} diff --git a/src/app/tip/__tests__/tip-screen.helpers.test.ts b/src/app/tip/__tests__/tip-screen.helpers.test.ts index 59ebaa86..137e365a 100644 --- a/src/app/tip/__tests__/tip-screen.helpers.test.ts +++ b/src/app/tip/__tests__/tip-screen.helpers.test.ts @@ -6,7 +6,6 @@ import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan"; import { findTipCoffeePlan, formatTipPrice, - isRealLoginStatus, } from "../tip-screen.helpers"; function makePlan(input: Partial): PaymentPlan { @@ -74,10 +73,4 @@ describe("tip screen helpers", () => { expect(formatTipPrice(null, "medium")).toBe("US$ 9.99"); expect(formatTipPrice(null, "large")).toBe("US$ 19.99"); }); - - it("treats guest and notLoggedIn as non-real login states", () => { - expect(isRealLoginStatus("guest")).toBe(false); - expect(isRealLoginStatus("notLoggedIn")).toBe(false); - expect(isRealLoginStatus("facebook")).toBe(true); - }); }); diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx index f09cffbc..0bd50538 100644 --- a/src/app/tip/tip-checkout-button.tsx +++ b/src/app/tip/tip-checkout-button.tsx @@ -17,7 +17,6 @@ const log = new Logger("TipCheckoutButton"); export interface TipCheckoutButtonProps { coffeeType: TipCoffeeType; disabled?: boolean; - isAuthLoading?: boolean; onOrder: () => void; returnPath: string; } @@ -25,7 +24,6 @@ export interface TipCheckoutButtonProps { export function TipCheckoutButton({ coffeeType, disabled = false, - isAuthLoading = false, onOrder, returnPath, }: TipCheckoutButtonProps) { @@ -42,8 +40,7 @@ export function TipCheckoutButton({ characterSlug: character.slug, }); - const isLoading = - isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder; + const isLoading = payment.isCreatingOrder || payment.isPollingOrder; const label = payment.isPollingOrder ? "Processing payment..." : payment.isCreatingOrder diff --git a/src/app/tip/tip-screen.helpers.ts b/src/app/tip/tip-screen.helpers.ts index b816b218..abb79f71 100644 --- a/src/app/tip/tip-screen.helpers.ts +++ b/src/app/tip/tip-screen.helpers.ts @@ -1,4 +1,3 @@ -import type { LoginStatus } from "@/data/schemas/auth"; import type { PaymentPlan } from "@/data/schemas/payment"; import { getTipCoffeeOption, @@ -30,7 +29,3 @@ export function formatTipPrice( if (currency.length > 0) return `${currency} ${formattedAmount}`; return formattedAmount; } - -export function isRealLoginStatus(loginStatus: LoginStatus): boolean { - return loginStatus !== "notLoggedIn" && loginStatus !== "guest"; -} diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index 8c56136c..8b31470d 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -23,12 +23,10 @@ import { TIP_COFFEE_OPTIONS, type TipCoffeeType, } from "@/lib/tip/tip_coffee"; -import { useAppNavigator } from "@/router/use-app-navigator"; import { useActiveCharacter, useActiveCharacterRoutes, } from "@/providers/character-provider"; -import { useAuthState } from "@/stores/auth/auth-context"; import { useUserState } from "@/stores/user/user-context"; import { TipCheckoutButton } from "./tip-checkout-button"; @@ -39,7 +37,6 @@ import { import { findTipCoffeePlan, formatTipPrice, - isRealLoginStatus, } from "./tip-screen.helpers"; import styles from "./tip-screen.module.css"; @@ -59,10 +56,8 @@ export function TipScreen({ shouldResumePendingOrder = false, initialPayChannel = null, }: TipScreenProps) { - const navigator = useAppNavigator(); const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); - const authState = useAuthState(); const userState = useUserState(); const paymentMethodConfig = getPaymentMethodConfig({ countryCode: userState.currentUser?.countryCode, @@ -125,7 +120,6 @@ export function TipScreen({ payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null; const isTierSelectionDisabled = payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; - const isAuthLoading = !authState.hasInitialized || authState.isLoading; const handlePaymentMethodChange = (payChannel: PayChannel) => { paymentDispatch({ @@ -176,16 +170,9 @@ export function TipScreen({ ]); const handleOrder = () => { - if (isAuthLoading) return; - if (coffeePlan) { - behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT); - } - if (!isRealLoginStatus(authState.loginStatus)) { - navigator.openAuth(returnPath); - return; - } if (!canCreateOrder) return; + behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT); paymentDispatch({ type: "PaymentCreateOrderSubmitted", recipientCharacterId: character.id, @@ -249,7 +236,8 @@ export function TipScreen({ {character.copy.tipTitle}

- Send a warm coffee tip and keep the private moments glowing. + ❤️ Thank you for being here. If you'd ever like to treat me to a + little coffee, I'd really appreciate it. ☕

@@ -322,11 +310,7 @@ export function TipScreen({