diff --git a/packages/shared/src/hooks/usePaddlePayment.spec.tsx b/packages/shared/src/hooks/usePaddlePayment.spec.tsx new file mode 100644 index 0000000000..b00bb30ff8 --- /dev/null +++ b/packages/shared/src/hooks/usePaddlePayment.spec.tsx @@ -0,0 +1,189 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { PaddleEventData } from '@paddle/paddle-js'; +import { CheckoutEventNames } from '@paddle/paddle-js'; +import { useRouter } from 'next/router'; +import type { NextRouter } from 'next/router'; +import { useAuthContext } from '../contexts/AuthContext'; +import { useLogContext } from '../contexts/LogContext'; +import { PurchaseType } from '../graphql/paddle'; +import { usePaddlePayment } from './usePaddlePayment'; + +const mockCheckout = { + open: jest.fn(), + updateItems: jest.fn(), +}; +const mockLogEvent = jest.fn(); +let mockEventCallback: ((event: PaddleEventData) => void) | undefined; + +jest.mock('@paddle/paddle-js', () => ({ + CheckoutEventNames: { + CHECKOUT_PAYMENT_INITIATED: 'checkout.payment.initiated', + CHECKOUT_LOADED: 'checkout.loaded', + CHECKOUT_PAYMENT_SELECTED: 'checkout.payment.selected', + CHECKOUT_COMPLETED: 'checkout.completed', + CHECKOUT_ERROR: 'checkout.error', + CHECKOUT_PAYMENT_FAILED: 'checkout.payment.failed', + CHECKOUT_CLOSED: 'checkout.closed', + CHECKOUT_ITEMS_UPDATED: 'checkout.items.updated', + CHECKOUT_DISCOUNT_APPLIED: 'checkout.discount.applied', + CHECKOUT_DISCOUNT_REMOVED: 'checkout.discount.removed', + }, + initializePaddle: jest.fn(({ eventCallback }) => { + mockEventCallback = eventCallback; + + return Promise.resolve({ + Checkout: mockCheckout, + }); + }), +})); + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../contexts/LogContext', () => ({ + useLogContext: jest.fn(), +})); + +const renderPaymentHook = async () => { + const view = renderHook(() => + usePaddlePayment({ + priceType: PurchaseType.Plus, + }), + ); + + await waitFor(() => { + expect(view.result.current.isPaddleReady).toBe(true); + }); + + return view; +}; + +const appendCheckoutContainer = ({ + withIframe = false, +}: { + withIframe?: boolean; +} = {}) => { + const container = document.createElement('div'); + container.className = 'checkout-container'; + + if (withIframe) { + container.append(document.createElement('iframe')); + } + + document.body.append(container); + + return container; +}; + +const emitCheckoutLoaded = () => { + if (!mockEventCallback) { + throw new Error('Paddle event callback was not registered'); + } + + act(() => { + mockEventCallback?.({ + name: CheckoutEventNames.CHECKOUT_LOADED, + data: { + custom_data: {}, + items: [{ quantity: 1 }], + payment: { + method_details: { + type: 'card', + }, + }, + }, + } as unknown as PaddleEventData); + }); +}; + +describe('usePaddlePayment', () => { + beforeEach(() => { + document.body.innerHTML = ''; + jest.clearAllMocks(); + mockEventCallback = undefined; + jest.mocked(useRouter).mockReturnValue({ + query: {}, + push: jest.fn(), + } as unknown as NextRouter); + jest.mocked(useAuthContext).mockReturnValue({ + user: { + id: 'user-id', + email: 'user@daily.dev', + }, + geo: { + region: 'US', + }, + trackingId: 'tracking-id', + } as ReturnType); + jest.mocked(useLogContext).mockReturnValue({ + logEvent: mockLogEvent, + logEventStart: jest.fn(), + logEventEnd: jest.fn(), + sendBeacon: jest.fn(), + } as ReturnType); + }); + + it('updates checkout items when Paddle has a loaded mounted iframe', async () => { + const { result } = await renderPaymentHook(); + appendCheckoutContainer({ withIframe: true }); + emitCheckoutLoaded(); + + act(() => { + result.current.openCheckout({ priceId: 'annual-price' }); + }); + + expect(mockCheckout.updateItems).toHaveBeenCalledWith([ + { priceId: 'annual-price', quantity: 1 }, + ]); + expect(mockCheckout.open).not.toHaveBeenCalled(); + }); + + it('reopens the inline checkout when Paddle loaded state points at a remounted container', async () => { + const { result } = await renderPaymentHook(); + appendCheckoutContainer({ withIframe: true }); + emitCheckoutLoaded(); + + document.body.innerHTML = ''; + appendCheckoutContainer(); + + act(() => { + result.current.openCheckout({ priceId: 'annual-price' }); + }); + + expect(mockCheckout.updateItems).not.toHaveBeenCalled(); + expect(mockCheckout.open).toHaveBeenCalledWith( + expect.objectContaining({ + items: [{ priceId: 'annual-price', quantity: 1 }], + }), + ); + }); + + it('reopens the inline checkout when Paddle updateItems throws a missing iframe error', async () => { + const { result } = await renderPaymentHook(); + appendCheckoutContainer({ withIframe: true }); + emitCheckoutLoaded(); + mockCheckout.updateItems.mockImplementationOnce(() => { + throw new TypeError( + "Cannot read properties of undefined (reading 'contentWindow')", + ); + }); + + act(() => { + result.current.openCheckout({ priceId: 'annual-price' }); + }); + + expect(mockCheckout.updateItems).toHaveBeenCalledWith([ + { priceId: 'annual-price', quantity: 1 }, + ]); + expect(mockCheckout.open).toHaveBeenCalledWith( + expect.objectContaining({ + items: [{ priceId: 'annual-price', quantity: 1 }], + }), + ); + }); +}); diff --git a/packages/shared/src/hooks/usePaddlePayment.ts b/packages/shared/src/hooks/usePaddlePayment.ts index d451bf20da..2d1b087876 100644 --- a/packages/shared/src/hooks/usePaddlePayment.ts +++ b/packages/shared/src/hooks/usePaddlePayment.ts @@ -19,6 +19,32 @@ import type { } from '../contexts/payment/context'; import { PlusPlanType, PurchaseType } from '../graphql/paddle'; +const checkoutContainerSelector = '.checkout-container'; +const checkoutIframeSelector = 'iframe'; +const missingCheckoutIframeError = 'contentWindow'; + +const getCheckoutContainers = (): HTMLElement[] => { + if (typeof document === 'undefined') { + return []; + } + + return Array.from( + document.querySelectorAll(checkoutContainerSelector), + ).filter((element) => element.isConnected); +}; + +const hasMountedCheckoutContainer = (): boolean => + getCheckoutContainers().length > 0; + +const hasMountedCheckoutIframe = (): boolean => + getCheckoutContainers().some((element) => + element.querySelector(checkoutIframeSelector), + ); + +const isMissingCheckoutIframeError = (error: unknown): boolean => + error instanceof TypeError && + error.message.includes(missingCheckoutIframeError); + interface UsePaddlePaymentProps extends Pick< PaymentContextProviderProps, @@ -40,11 +66,12 @@ export const usePaddlePayment = ({ const { user, geo, trackingId } = useAuthContext(); const [paddle, setPaddle] = useState(); const isCheckoutOpenRef = useRef(false); + const scheduledOpenFrameRef = useRef(); const [checkoutItemsLoading, setCheckoutItemsLoading] = useState(false); const [appliedDiscountId, setAppliedDiscountId] = useState( null, ); - const logRef = useRef(); + const logRef = useRef(logEvent); logRef.current = logEvent; const successCallbackRef = useRef(successCallback); successCallbackRef.current = successCallback; @@ -67,7 +94,7 @@ export const usePaddlePayment = ({ environment: (process.env.NEXT_PUBLIC_PADDLE_ENVIRONMENT as Environments) || 'production', - token: process.env.NEXT_PUBLIC_PADDLE_TOKEN, + token: process.env.NEXT_PUBLIC_PADDLE_TOKEN as string, eventCallback: (event: PaddleEventData) => { if (disabledEvents?.includes(event?.name as CheckoutEventNames)) { if (event?.name === CheckoutEventNames.CHECKOUT_LOADED) { @@ -132,11 +159,11 @@ export const usePaddlePayment = ({ ? customData.user_id : undefined, quantity: getProductQuantityRef.current?.(event), - localCost: event?.data.totals.total, - localCurrency: event?.data.currency_code, - payment: event?.data.payment.method_details.type, + localCost: event?.data?.totals.total, + localCurrency: event?.data?.currency_code, + payment: event?.data?.payment.method_details.type, cycle: - event?.data.items?.[0]?.billing_cycle?.interval ?? 'one-off', + event?.data?.items?.[0]?.billing_cycle?.interval ?? 'one-off', ...plusPlanExtra, }), }); @@ -209,6 +236,17 @@ export const usePaddlePayment = ({ }); }, [router, disabledEvents, targetType, isOrganization, isPlusPlan]); + useEffect( + () => () => { + if (typeof window === 'undefined' || !scheduledOpenFrameRef.current) { + return; + } + + window.cancelAnimationFrame(scheduledOpenFrameRef.current); + }, + [], + ); + const openCheckout = useCallback( ({ priceId, @@ -245,18 +283,65 @@ export const usePaddlePayment = ({ ...(!!giftToUserId && { gifter_id: user?.id }), }; - if (isCheckoutOpenRef.current) { - setCheckoutItemsLoading(true); - paddle?.Checkout.updateItems(items); - return; - } - - paddle?.Checkout.open({ + const checkoutOptions = { items, customer, customData, discountId: discountIdQuery || discountId, - }); + }; + + const openInlineCheckout = () => { + if (!paddle?.Checkout) { + return; + } + + const open = () => { + if (!hasMountedCheckoutContainer()) { + return; + } + + isCheckoutOpenRef.current = false; + setCheckoutItemsLoading(false); + paddle.Checkout.open(checkoutOptions); + }; + + if (hasMountedCheckoutContainer()) { + open(); + return; + } + + if (typeof window === 'undefined') { + return; + } + + if (scheduledOpenFrameRef.current) { + window.cancelAnimationFrame(scheduledOpenFrameRef.current); + } + + scheduledOpenFrameRef.current = window.requestAnimationFrame(() => { + scheduledOpenFrameRef.current = undefined; + open(); + }); + }; + + if (isCheckoutOpenRef.current && hasMountedCheckoutIframe()) { + setCheckoutItemsLoading(true); + try { + paddle?.Checkout.updateItems(items); + } catch (error) { + setCheckoutItemsLoading(false); + + if (isMissingCheckoutIframeError(error)) { + openInlineCheckout(); + return; + } + + throw error; + } + return; + } + + openInlineCheckout(); }, [ paddle?.Checkout,