fix(tip): allow checkout for all auth states

This commit is contained in:
2026-07-20 18:36:44 +08:00
parent 1e13f94b5d
commit c187f0b817
5 changed files with 196 additions and 36 deletions
@@ -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;
}) => (
<button
type="button"
data-testid="tip-checkout"
disabled={disabled}
onClick={onOrder}
>
Checkout
</button>
),
}));
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(<TipScreen />));
}
function getCheckoutButton(): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>(
'[data-testid="tip-checkout"]',
);
if (!button) throw new Error("Missing Tip checkout button");
return button;
}
});
function makePaymentState(
overrides: Partial<PaymentContextState> = {},
): 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,
};
}
@@ -6,7 +6,6 @@ import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
import { import {
findTipCoffeePlan, findTipCoffeePlan,
formatTipPrice, formatTipPrice,
isRealLoginStatus,
} from "../tip-screen.helpers"; } from "../tip-screen.helpers";
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan { function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
@@ -74,10 +73,4 @@ describe("tip screen helpers", () => {
expect(formatTipPrice(null, "medium")).toBe("US$ 9.99"); expect(formatTipPrice(null, "medium")).toBe("US$ 9.99");
expect(formatTipPrice(null, "large")).toBe("US$ 19.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);
});
}); });
+1 -4
View File
@@ -17,7 +17,6 @@ const log = new Logger("TipCheckoutButton");
export interface TipCheckoutButtonProps { export interface TipCheckoutButtonProps {
coffeeType: TipCoffeeType; coffeeType: TipCoffeeType;
disabled?: boolean; disabled?: boolean;
isAuthLoading?: boolean;
onOrder: () => void; onOrder: () => void;
returnPath: string; returnPath: string;
} }
@@ -25,7 +24,6 @@ export interface TipCheckoutButtonProps {
export function TipCheckoutButton({ export function TipCheckoutButton({
coffeeType, coffeeType,
disabled = false, disabled = false,
isAuthLoading = false,
onOrder, onOrder,
returnPath, returnPath,
}: TipCheckoutButtonProps) { }: TipCheckoutButtonProps) {
@@ -42,8 +40,7 @@ export function TipCheckoutButton({
characterSlug: character.slug, characterSlug: character.slug,
}); });
const isLoading = const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder;
const label = payment.isPollingOrder const label = payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
-5
View File
@@ -1,4 +1,3 @@
import type { LoginStatus } from "@/data/schemas/auth";
import type { PaymentPlan } from "@/data/schemas/payment"; import type { PaymentPlan } from "@/data/schemas/payment";
import { import {
getTipCoffeeOption, getTipCoffeeOption,
@@ -30,7 +29,3 @@ export function formatTipPrice(
if (currency.length > 0) return `${currency} ${formattedAmount}`; if (currency.length > 0) return `${currency} ${formattedAmount}`;
return formattedAmount; return formattedAmount;
} }
export function isRealLoginStatus(loginStatus: LoginStatus): boolean {
return loginStatus !== "notLoggedIn" && loginStatus !== "guest";
}
+4 -20
View File
@@ -23,12 +23,10 @@ import {
TIP_COFFEE_OPTIONS, TIP_COFFEE_OPTIONS,
type TipCoffeeType, type TipCoffeeType,
} from "@/lib/tip/tip_coffee"; } from "@/lib/tip/tip_coffee";
import { useAppNavigator } from "@/router/use-app-navigator";
import { import {
useActiveCharacter, useActiveCharacter,
useActiveCharacterRoutes, useActiveCharacterRoutes,
} from "@/providers/character-provider"; } from "@/providers/character-provider";
import { useAuthState } from "@/stores/auth/auth-context";
import { useUserState } from "@/stores/user/user-context"; import { useUserState } from "@/stores/user/user-context";
import { TipCheckoutButton } from "./tip-checkout-button"; import { TipCheckoutButton } from "./tip-checkout-button";
@@ -39,7 +37,6 @@ import {
import { import {
findTipCoffeePlan, findTipCoffeePlan,
formatTipPrice, formatTipPrice,
isRealLoginStatus,
} from "./tip-screen.helpers"; } from "./tip-screen.helpers";
import styles from "./tip-screen.module.css"; import styles from "./tip-screen.module.css";
@@ -59,10 +56,8 @@ export function TipScreen({
shouldResumePendingOrder = false, shouldResumePendingOrder = false,
initialPayChannel = null, initialPayChannel = null,
}: TipScreenProps) { }: TipScreenProps) {
const navigator = useAppNavigator();
const character = useActiveCharacter(); const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const authState = useAuthState();
const userState = useUserState(); const userState = useUserState();
const paymentMethodConfig = getPaymentMethodConfig({ const paymentMethodConfig = getPaymentMethodConfig({
countryCode: userState.currentUser?.countryCode, countryCode: userState.currentUser?.countryCode,
@@ -125,7 +120,6 @@ export function TipScreen({
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null; payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null;
const isTierSelectionDisabled = const isTierSelectionDisabled =
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy;
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
const handlePaymentMethodChange = (payChannel: PayChannel) => { const handlePaymentMethodChange = (payChannel: PayChannel) => {
paymentDispatch({ paymentDispatch({
@@ -176,16 +170,9 @@ export function TipScreen({
]); ]);
const handleOrder = () => { const handleOrder = () => {
if (isAuthLoading) return;
if (coffeePlan) {
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
}
if (!isRealLoginStatus(authState.loginStatus)) {
navigator.openAuth(returnPath);
return;
}
if (!canCreateOrder) return; if (!canCreateOrder) return;
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
paymentDispatch({ paymentDispatch({
type: "PaymentCreateOrderSubmitted", type: "PaymentCreateOrderSubmitted",
recipientCharacterId: character.id, recipientCharacterId: character.id,
@@ -249,7 +236,8 @@ export function TipScreen({
{character.copy.tipTitle} {character.copy.tipTitle}
</h1> </h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
Send a warm coffee tip and keep the private moments glowing. Thank you for being here. If you&apos;d ever like to treat me to a
little coffee, I&apos;d really appreciate it.
</p> </p>
</section> </section>
@@ -322,11 +310,7 @@ export function TipScreen({
<div className={styles.checkoutSlot}> <div className={styles.checkoutSlot}>
<TipCheckoutButton <TipCheckoutButton
coffeeType={selectedCoffeeType} coffeeType={selectedCoffeeType}
disabled={ disabled={showMissingPlan || !canCreateOrder}
showMissingPlan ||
(!canCreateOrder && isRealLoginStatus(authState.loginStatus))
}
isAuthLoading={isAuthLoading}
onOrder={handleOrder} onOrder={handleOrder}
returnPath={returnPath} returnPath={returnPath}
/> />