fix(tip): allow checkout for all auth states
This commit is contained in:
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'd ever like to treat me to a
|
||||||
|
little coffee, I'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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user