UNPKG

@paypal/react-paypal-js

Version:
1,539 lines (1,521 loc) 109 kB
"use client"; /*! * react-paypal-js v9.2.0 (2026-04-27T17:34:47.479Z) * Copyright 2020-present, PayPal, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import React, { createContext, useContext, useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect, useReducer } from 'react'; import { loadCoreSdkScript } from '@paypal/paypal-js/sdk-v6'; var INSTANCE_LOADING_STATE; (function (INSTANCE_LOADING_STATE) { INSTANCE_LOADING_STATE["PENDING"] = "pending"; INSTANCE_LOADING_STATE["RESOLVED"] = "resolved"; INSTANCE_LOADING_STATE["REJECTED"] = "rejected"; })(INSTANCE_LOADING_STATE || (INSTANCE_LOADING_STATE = {})); var INSTANCE_DISPATCH_ACTION; (function (INSTANCE_DISPATCH_ACTION) { INSTANCE_DISPATCH_ACTION["SET_LOADING_STATUS"] = "setLoadingStatus"; INSTANCE_DISPATCH_ACTION["SET_INSTANCE"] = "setInstance"; INSTANCE_DISPATCH_ACTION["SET_ELIGIBILITY"] = "setEligibility"; INSTANCE_DISPATCH_ACTION["SET_ERROR"] = "setError"; INSTANCE_DISPATCH_ACTION["RESET_STATE"] = "resetState"; })(INSTANCE_DISPATCH_ACTION || (INSTANCE_DISPATCH_ACTION = {})); var BRAINTREE_DISPATCH_ACTION; (function (BRAINTREE_DISPATCH_ACTION) { BRAINTREE_DISPATCH_ACTION["SET_LOADING_STATUS"] = "setLoadingStatus"; BRAINTREE_DISPATCH_ACTION["SET_INSTANCE"] = "setInstance"; BRAINTREE_DISPATCH_ACTION["SET_ERROR"] = "setError"; BRAINTREE_DISPATCH_ACTION["RESET_STATE"] = "resetState"; })(BRAINTREE_DISPATCH_ACTION || (BRAINTREE_DISPATCH_ACTION = {})); // ---- Shared types ---- function validateBraintreeNamespace(namespace) { const ns = namespace; return typeof ns?.client?.create === "function" && typeof ns?.paypalCheckoutV6?.create === "function"; } const initialState = { sdkInstance: null, eligiblePaymentMethods: null, eligiblePaymentMethodsPayload: null, loadingStatus: INSTANCE_LOADING_STATE.PENDING, error: null, isHydrated: false }; function instanceReducer(state, action) { switch (action.type) { case INSTANCE_DISPATCH_ACTION.SET_LOADING_STATUS: return { ...state, loadingStatus: action.value }; case INSTANCE_DISPATCH_ACTION.SET_INSTANCE: return { ...state, sdkInstance: action.value, loadingStatus: INSTANCE_LOADING_STATE.RESOLVED }; case INSTANCE_DISPATCH_ACTION.SET_ELIGIBILITY: return { ...state, eligiblePaymentMethods: action.value.eligiblePaymentMethods, eligiblePaymentMethodsPayload: action.value.payload }; case INSTANCE_DISPATCH_ACTION.SET_ERROR: return { ...state, error: action.value, loadingStatus: INSTANCE_LOADING_STATE.REJECTED }; case INSTANCE_DISPATCH_ACTION.RESET_STATE: return initialState; default: return state; } } const PayPalContext = createContext(null); /** * Returns {@link PayPalState} provided by a parent {@link PayPalProvider}. * * @returns {PayPalState} */ function usePayPal() { const context = useContext(PayPalContext); if (context === null) { throw new Error("usePayPal must be used within a PayPalProvider"); } return context; } /** * Performs a shallow equality check on two arrays. * * This function compares two arrays element-by-element using strict equality (===). * It's primarily used to compare the `components` prop arrays passed to PayPalProvider * to prevent unnecessary re-initialization of the PayPal SDK. * * This optimization is important because re-initializing the SDK is an expensive operation * that involves loading scripts and setting up PayPal integrations. * * @param arr1 - First array to compare * @param arr2 - Second array to compare * @returns `true` if both arrays are null/undefined, or if they contain the same elements in the same order * * @example * // Returns true - both arrays have the same elements in the same order * shallowEqualArray( * ["paypal-payments", "venmo-payments"], * ["paypal-payments", "venmo-payments"] * ); * * @example * // Returns false - different order * shallowEqualArray( * ["paypal-payments", "venmo-payments"], * ["venmo-payments", "paypal-payments"] * ); * * @example * // Returns true - both are null * shallowEqualArray(null, null); */ function shallowEqualArray(arr1, arr2) { if (!arr1 && !arr2) { return true; } if (!arr1 || !arr2) { return false; } if (arr1.length !== arr2.length) { return false; } for (let i = 0; i < arr1.length; i++) { if (arr1[i] !== arr2[i]) { return false; } } return true; } /** * Custom hook that memoizes a components array based on shallow equality comparison. * Returns a stable reference when the array contents haven't changed. * * This allows developers to pass inline component arrays without causing unnecessary re-renders * when the array values are the same, even if the array reference changes. * * @param value - The components array to memoize * @returns A stable reference to the components array * * @example * const memoizedComponents = useCompareMemoize(["paypal-payments", "venmo-payments"]); */ function useCompareMemoize(value) { const ref = useRef(value); if (!shallowEqualArray(ref.current, value)) { ref.current = value; } return ref.current; } function useProxyProps(props) { const proxyRef = useRef(new Proxy({}, { get(target, prop, receiver) { /** * * If target[prop] is a function, return a function that accesses * this function off the target object. We can mutate the target with * new copies of this function without having to re-render the * SDK components to pass new callbacks. * * */ if (typeof target[prop] === "function") { return (...args) => target[prop](...args); } return Reflect.get(target, prop, receiver); } })); proxyRef.current = Object.assign(proxyRef.current, props); return proxyRef.current; } /** * Normalize input to an {@link Error} instance. * * @param {unknown} error - this argument will be coerced into a String then passed into a new * {@link Error}. If it's already an {@link Error} instance, it will be returned without modification. * @returns {Error} * * @example * toError("An error occurred"); * * @example * const myError = new Error("An error occurred"); * toError(myError); */ function toError(error) { if (error instanceof Error) { return error; } return new Error(String(error)); } /** * Custom hook that memoizes a value based on deep equality comparison. * Returns a stable reference when the value hasn't changed, even if the * object or array reference is new. * * This allows developers to pass inline objects or arrays without causing * unnecessary re-renders or effect re-runs when the values are the same. * * @param value - The value to memoize * @returns A stable reference to the value * * @example * const memoizedAmount = useDeepCompareMemoize({ value: "10.00", currencyCode: "USD" }); */ function useDeepCompareMemoize(value) { const ref = useRef(); if (!ref.current || !deepEqual(value, ref.current.value)) { ref.current = { value }; } return ref.current.value; } /** * Performs a recursive deep equality check on two values. * * Handles primitives, null/undefined, arrays, and plain objects. Recursion is * bounded by `maxDepth` (default: 10) to prevent stack overflow on deeply nested * structures — comparison returns `false` if the limit is exceeded. * * @param obj1 - First value to compare * @param obj2 - Second value to compare * @param maxDepth - Maximum recursion depth (default: 10) * @param currentDepth - Current recursion depth, used internally * @returns `true` if both values are deeply equal, `false` otherwise * * @example * deepEqual({ amount: "10.00", currency: "USD" }, { amount: "10.00", currency: "USD" }); // true * deepEqual({ amount: "10.00" }, { amount: "20.00" }); // false */ // eslint-disable-next-line max-params function deepEqual(obj1, obj2, maxDepth = 10, currentDepth = 0) { // Prevent infinite recursion by limiting depth if (currentDepth > maxDepth) { return false; } // Handle primitives and same reference if (obj1 === obj2) { return true; } // Handle null/undefined if (obj1 === null || obj1 === undefined || obj2 === null || obj2 === undefined) { return false; } // Different types are not equal if (typeof obj1 !== typeof obj2) { return false; } // Non-object primitives (number, string, boolean, function, symbol, bigint) that // Same typeof (verified above) but not ===, so unequal primitives if (typeof obj1 !== "object") { return false; } // Handle Arrays if (Array.isArray(obj1) && Array.isArray(obj2)) { if (obj1.length !== obj2.length) { return false; } for (let i = 0; i < obj1.length; i++) { if (!deepEqual(obj1[i], obj2[i], maxDepth, currentDepth + 1)) { return false; } } return true; } // One is array, the other is not if (Array.isArray(obj1) || Array.isArray(obj2)) { return false; } // At this point, we know both are non-null objects const record1 = obj1; const record2 = obj2; const keys1 = Object.keys(record1); const keys2 = Object.keys(record2); if (keys1.length !== keys2.length) { return false; } for (const key of keys1) { if (!deepEqual(record1[key], record2[key], maxDepth, currentDepth + 1)) { return false; } } return true; } /** * Creates a payment session with error handling and retry prevention. * * @param options - Configuration for creating the payment session * @returns The payment session or null if creation fails * * @example * const session = createPaymentSession({ * sessionCreator: () => sdkInstance.createPayPalOneTimePaymentSession({ orderId, ...callbacks }), * failedSdkRef, * sdkInstance, * setError, * errorMessage: 'Failed to create payment session. This may occur if the required component "paypal-payments" is not included in the SDK components array.', * }); * * if (!session) return; */ function createPaymentSession({ sessionCreator, failedSdkRef, sdkInstance, setError, errorMessage }) { // Skip retry if this SDK instance already failed if (failedSdkRef.current === sdkInstance) { return null; } try { return sessionCreator(); } catch (err) { failedSdkRef.current = sdkInstance; const detailedError = new Error(errorMessage, { cause: err }); setError(detailedError); return null; } } const CardFieldsSessionContext = createContext(null); const CardFieldsStatusContext = createContext(null); /** * Centralized hook for handling {@link Error}s in a consistent manner. * * @param {Boolean} noConsoleErrors - set to `true` to prevent `setError` calls from logging to `console.error`. */ function useError(noConsoleErrors = false) { const [error, setErrorInternal] = useState(null); const setError = useCallback(newError => { setErrorInternal(newError); if (!noConsoleErrors && newError) { console.error(newError); } }, [noConsoleErrors]); return [error, setError]; } const CARD_FIELDS_SESSION_TYPES = { ONE_TIME_PAYMENT: "one-time-payment", SAVE_PAYMENT: "save-payment" }; /** * {@link PayPalCardFieldsProvider} creates a Card Fields session and provides it to child components. * * @remarks * Child components must use either {@link usePayPalCardFieldsOneTimePaymentSession} or * {@link usePayPalCardFieldsSavePaymentSession} to initialize the appropriate session type. * The session will not be created until one of these hooks is called. * * @example * // Amount can be updated dynamically * const [amount, setAmount] = useState<OrderAmount>({ value: "10.00", currencyCode: "USD" }); * const onBlur = useCallback((event) => { ... }, []); * <PayPalProvider * components={["card-fields"]} * clientToken={clientToken} * pageType="checkout" * > * <PayPalCardFieldsProvider * blur={onBlur} * validitychange={(event) => console.log('Validity:', event)} * cardtypechange={(event) => console.log('Card type:', event)} * amount={amount} * isCobrandedEligible={true} * > * <CheckoutForm /> * </PayPalCardFieldsProvider> * </PayPalProvider> */ const PayPalCardFieldsProvider = ({ children, amount, isCobrandedEligible, ...eventHandlers }) => { const { sdkInstance, loadingStatus } = usePayPal(); const [cardFieldsSession, setCardFieldsSession] = useState(null); const [cardFieldsSessionType, setCardFieldsSessionType] = useState(null); const [cardFieldsError, setCardFieldsError] = useState(null); const [, setError] = useError(); // Use proxy props for event handlers to avoid re-renders const proxyEventHandlers = useProxyProps(eventHandlers); // Memoize amount to avoid unnecessary updates when value hasn't changed const memoizedAmount = useDeepCompareMemoize(amount); const handleError = useCallback(error => { setError(error); setCardFieldsError(error); }, [setError]); // Effect to create Card Fields session useEffect(() => { // Early return: Still loading, wait for sdkInstance if (loadingStatus === INSTANCE_LOADING_STATE.PENDING) { return; } // Error case: Loading finished but no sdkInstance if (!sdkInstance) { handleError(toError("no sdk instance available")); return; } // Clear previous sdkInstance loading errors handleError(null); // Wait for session type to be set by child hooks if (!cardFieldsSessionType) { return; } // Create Card Fields session based on sessionType let newCardFieldsSession; try { newCardFieldsSession = cardFieldsSessionType === CARD_FIELDS_SESSION_TYPES.ONE_TIME_PAYMENT ? sdkInstance.createCardFieldsOneTimePaymentSession() : sdkInstance.createCardFieldsSavePaymentSession(); setCardFieldsSession(newCardFieldsSession); } catch (error) { handleError(toError(error)); } // Cleanup: destroy session on unmount or when dependencies change return () => { newCardFieldsSession?.destroy(); setCardFieldsSession(null); }; }, [sdkInstance, loadingStatus, cardFieldsSessionType, handleError]); /** * Registers Card Fields event handlers with the session. * Uses useProxyProps for stable handler references and useDeepCompareMemoize to avoid * unnecessary re-registrations when handler values have not changed. * * @remarks * For best performance, wrap handler functions with useCallback or useDeepCompareMemoize * before passing them as props to avoid unnecessary SDK event handler re-registrations. */ useEffect(() => { if (!cardFieldsSession) { return; } try { /* Register all event handlers that are defined by iterating over the keys of proxyEventHandlers directly */ Object.keys(proxyEventHandlers).forEach(eventName => { const handler = proxyEventHandlers[eventName]; if (handler && typeof handler === "function") { cardFieldsSession.on(eventName, handler); } }); } catch (error) { handleError(toError(`Failed to register event handlers: ${error}`)); } }, [cardFieldsSession, proxyEventHandlers, handleError]); // Update session configuration when props change useEffect(() => { if (!cardFieldsSession) { return; } // Build update configuration from props const updateOptions = {}; let hasUpdates = false; if (memoizedAmount !== undefined) { updateOptions.amount = memoizedAmount; hasUpdates = true; } if (isCobrandedEligible !== undefined) { updateOptions.isCobrandedEligible = isCobrandedEligible; hasUpdates = true; } // Only call update if there are configuration changes if (!hasUpdates) { return; } try { cardFieldsSession.update(updateOptions); } catch (error) { handleError(toError(`Failed to update card fields configuration: ${error}`)); } }, [cardFieldsSession, memoizedAmount, isCobrandedEligible, handleError]); const sessionContextValue = useMemo(() => ({ cardFieldsSession, setCardFieldsSessionType, setError: handleError }), [cardFieldsSession, setCardFieldsSessionType, handleError]); const statusContextValue = useMemo(() => ({ error: cardFieldsError }), [cardFieldsError]); return React.createElement(CardFieldsSessionContext.Provider, { value: sessionContextValue }, React.createElement(CardFieldsStatusContext.Provider, { value: statusContextValue }, children)); }; /** * Return a {@link React.MutableRefObject} a stable ref that's `true` if the component is mounted, `false` otherwise. * * The return must, unfortunately be included in dependency arrays. See the issue here: [\[eslint-plugin-react-hooks\] allow configuring custom hooks as "static" #16873](https://github.com/facebook/react/issues/16873). */ function useIsMountedRef() { const isMounted = useRef(false); useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; }; }, []); return isMounted; } /** * Hook for managing Pay Later one-time payment sessions. * * This hook creates and manages a Pay Later payment session. It handles session lifecycle, resume flows * for redirect-based presentation modes (`"redirect"` and `"direct-app-switch"`), and provides methods * to start, cancel, and destroy the session. * * @returns Object with: `error` (any session error), `isPending` (SDK loading), `handleClick` (starts session), `handleCancel` (cancels session), `handleDestroy` (cleanup) * * @example * function PayLaterCheckoutButton() { * const { error, isPending, handleClick, handleCancel } = usePayLaterOneTimePaymentSession({ * presentationMode: 'popup', * createOrder: async () => ({ orderId: 'ORDER-123' }), * onApprove: (data) => console.log('Approved:', data), * onCancel: () => console.log('Cancelled'), * }); * const { eligiblePaymentMethods } = usePayPal(); * const payLaterDetails = eligiblePaymentMethods?.getDetails?.("paylater"); * * if (isPending) return null; * if (error) return <div>Error: {error.message}</div>; * * return ( * <paypal-pay-later-button * countryCode={payLaterDetails?.countryCode} * productCode={payLaterDetails?.productCode} * onClick={handleClick} * onCancel={handleCancel} * /> * ); * } */ function usePayLaterOneTimePaymentSession({ presentationMode, fullPageOverlay, autoRedirect, createOrder, orderId, ...callbacks }) { const { sdkInstance, loadingStatus } = usePayPal(); const isMountedRef = useIsMountedRef(); const sessionRef = useRef(null); const proxyCallbacks = useProxyProps(callbacks); const [error, setError] = useError(); // Prevents retrying session creation with a failed SDK instance const failedSdkRef = useRef(null); const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING; const handleDestroy = useCallback(() => { sessionRef.current?.destroy(); sessionRef.current = null; }, []); // Handle SDK availability useEffect(() => { // Reset failed SDK tracking when SDK instance changes if (failedSdkRef.current !== sdkInstance) { failedSdkRef.current = null; } if (sdkInstance) { setError(null); } else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) { setError(new Error("no sdk instance available")); } }, [sdkInstance, setError, loadingStatus]); // Create and manage session lifecycle useEffect(() => { if (!sdkInstance) { return; } const newSession = createPaymentSession({ sessionCreator: () => sdkInstance.createPayLaterOneTimePaymentSession({ orderId, ...proxyCallbacks }), failedSdkRef, sdkInstance, setError, errorMessage: 'Failed to create payment session. This may occur if the required component "paypal-payments" is not included in the SDK components array.' }); if (!newSession) { return; } sessionRef.current = newSession; // check for resume flow in redirect-based presentation modes const isRedirectMode = presentationMode === "redirect" || presentationMode === "direct-app-switch"; if (isRedirectMode) { const handleReturnFromPayPal = async () => { try { if (!newSession) { return; } const isResumeFlow = newSession.hasReturned?.(); if (isResumeFlow) { await newSession.resume?.(); } } catch (err) { setError(err); } }; handleReturnFromPayPal(); } return () => { newSession.destroy(); }; }, [sdkInstance, orderId, proxyCallbacks, presentationMode, setError]); const handleCancel = useCallback(() => { sessionRef.current?.cancel(); }, []); const handleClick = useCallback(async () => { if (!isMountedRef.current) { return; } if (!sessionRef.current) { setError(new Error("PayLater session not available")); return; } const startOptions = { presentationMode, fullPageOverlay, autoRedirect }; const result = await sessionRef.current.start(startOptions, createOrder?.()); return result; }, [createOrder, presentationMode, fullPageOverlay, autoRedirect, isMountedRef, setError]); return { error, isPending, handleCancel, handleClick, handleDestroy }; } /** * `PayLaterOneTimePaymentButton` is a button that provides a PayLater payment flow. * * `PayLaterOneTimePaymentButtonProps` combines the arguments for {@link UsePayLaterOneTimePaymentSessionProps} * with a `disabled` prop. * * The `countryCode` and `productCode` are automatically populated from the eligibility API response * (available via `usePayPal().eligiblePaymentMethods`). The button requires eligibility to be configured * in the parent `PayPalProvider`. * * Note, `autoRedirect` is not allowed because if given a `presentationMode` of `"redirect"` the button * would not be able to provide back `redirectURL` from `start`. Advanced integrations that need * `redirectURL` should use the {@link usePayLaterOneTimePaymentSession} hook directly. * * @example * <PayLaterOneTimePaymentButton * onApprove={() => { * // ... on approve logic * }} * orderId="your-order-id" * presentationMode="auto" * /> */ const PayLaterOneTimePaymentButton = ({ disabled = false, ...hookProps }) => { const { eligiblePaymentMethods, isHydrated } = usePayPal(); const { error, isPending, handleClick } = usePayLaterOneTimePaymentSession(hookProps); const payLaterDetails = eligiblePaymentMethods?.getDetails("paylater"); const countryCode = payLaterDetails?.countryCode; const productCode = payLaterDetails?.productCode; useEffect(() => { if (error) { console.error(error); } }, [error]); if (isPending) { return null; } return isHydrated ? React.createElement("paypal-pay-later-button", { onClick: handleClick, countryCode: countryCode, productCode: productCode, disabled: disabled || !!error || undefined }) : React.createElement("div", null); }; /** * Hook for managing PayPal Credit one-time payment sessions. * * This hook creates and manages a PayPal Credit payment session. It handles session lifecycle, resume flows * for redirect-based flows, and provides methods to start, cancel, and destroy the session. * * @returns Object with: `error` (any session error), `isPending` (SDK loading), `handleClick` (starts session), `handleCancel` (cancels session), `handleDestroy` (cleanup) * * @example * function CreditCheckoutButton() { * const { error, isPending, handleClick, handleCancel } = usePayPalCreditOneTimePaymentSession({ * presentationMode: 'popup', * createOrder: async () => ({ orderId: 'ORDER-123' }), * onApprove: (data) => console.log('Approved:', data), * onCancel: () => console.log('Cancelled'), * }); * const { eligiblePaymentMethods } = usePayPal(); * const creditDetails = eligiblePaymentMethods?.getDetails?.("credit"); * * if (isPending) return null; * if (error) return <div>Error: {error.message}</div>; * * return ( * <paypal-credit-button * countryCode={creditDetails?.countryCode} * onClick={handleClick} * onCancel={handleCancel} * /> * ); * } */ function usePayPalCreditOneTimePaymentSession({ presentationMode, fullPageOverlay, autoRedirect, createOrder, orderId, ...callbacks }) { const { sdkInstance, loadingStatus } = usePayPal(); const isMountedRef = useIsMountedRef(); const sessionRef = useRef(null); const proxyCallbacks = useProxyProps(callbacks); const [error, setError] = useError(); // Prevents retrying session creation with a failed SDK instance const failedSdkRef = useRef(null); const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING; const handleDestroy = useCallback(() => { sessionRef.current?.destroy(); sessionRef.current = null; }, []); const handleCancel = useCallback(() => { sessionRef.current?.cancel(); }, []); // Handle SDK availability useEffect(() => { // Reset failed SDK tracking when SDK instance changes if (failedSdkRef.current !== sdkInstance) { failedSdkRef.current = null; } if (sdkInstance) { setError(null); } else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) { setError(new Error("no sdk instance available")); } }, [sdkInstance, setError, loadingStatus]); // Create and manage session lifecycle useEffect(() => { if (!sdkInstance) { return; } const newSession = createPaymentSession({ sessionCreator: () => sdkInstance.createPayPalCreditOneTimePaymentSession({ orderId, ...proxyCallbacks }), failedSdkRef, sdkInstance, setError, errorMessage: 'Failed to create payment session. This may occur if the required component "paypal-payments" is not included in the SDK components array.' }); if (!newSession) { return; } sessionRef.current = newSession; // Only check for resume flow in redirect-based presentation modes const shouldCheckResume = presentationMode === "redirect" || presentationMode === "direct-app-switch"; if (shouldCheckResume) { const handleReturnFromPayPal = async () => { try { if (!newSession) { return; } const isResumeFlow = newSession.hasReturned?.(); if (isResumeFlow) { await newSession.resume?.(); } } catch (err) { setError(err); } }; handleReturnFromPayPal(); } return () => { newSession.destroy(); }; }, [sdkInstance, orderId, proxyCallbacks, presentationMode, setError]); const handleClick = useCallback(async () => { if (!isMountedRef.current) { return; } if (!sessionRef.current) { setError(new Error("PayPal session not available")); return; } const startOptions = { presentationMode, fullPageOverlay, autoRedirect }; const result = await sessionRef.current.start(startOptions, createOrder?.()); return result; }, [isMountedRef, presentationMode, fullPageOverlay, autoRedirect, createOrder, setError]); return { error, isPending, handleClick, handleDestroy, handleCancel }; } /** * `PayPalCreditOneTimePaymentButton` is a button that provides a PayPal Credit payment flow. * * The `countryCode` is automatically populated from the eligibility API response * (available via `usePayPal().eligiblePaymentMethods`). The button requires eligibility to be configured * in the parent `PayPalProvider`, using either the `useEligibleMethods` hook client-side or `useFetchEligibleMethods` server-side. * * Note, `autoRedirect` is not allowed because if given a `presentationMode` of `"redirect"` the button * would not be able to provide back `redirectURL` from `start`. Advanced integrations that need * `redirectURL` should use the {@link usePayPalCreditOneTimePaymentSession} hook directly. * * @example * <PayPalCreditOneTimePaymentButton * onApprove={() => { * // ... on approve logic * }} * orderId="your-order-id" * presentationMode="auto" * /> */ const PayPalCreditOneTimePaymentButton = ({ disabled = false, ...hookProps }) => { const { eligiblePaymentMethods, isHydrated } = usePayPal(); const { error, isPending, handleClick } = usePayPalCreditOneTimePaymentSession(hookProps); const creditDetails = eligiblePaymentMethods?.getDetails("credit"); const countryCode = creditDetails?.countryCode; useEffect(() => { if (error) { console.error(error); } }, [error]); if (isPending) { return null; } return isHydrated ? React.createElement("paypal-credit-button", { onClick: handleClick, countryCode: countryCode, disabled: disabled || !!error || undefined }) : React.createElement("div", null); }; /** * Hook for managing PayPal Credit save payment sessions. * * This hook creates and manages a PayPal Credit save payment session for vaulting payment methods. * It handles session lifecycle, resume flows for redirect-based flows, and provides methods to start, cancel, and destroy the session. * * @returns Object with: `error` (any session error), `isPending` (SDK loading), `handleClick` (starts session), `handleCancel` (cancels session), `handleDestroy` (cleanup) * * @example * function SaveCreditButton() { * const { error, isPending, handleClick, handleCancel } = usePayPalCreditSavePaymentSession({ * presentationMode: 'redirect', * createVaultToken: async () => ({ vaultSetupToken: 'VAULT-TOKEN-123' }), * onApprove: (data) => console.log('Vaulted:', data), * onCancel: () => console.log('Cancelled'), * }); * const { eligiblePaymentMethods } = usePayPal(); * const creditDetails = eligiblePaymentMethods?.getDetails?.("credit"); * * if (isPending) return null; * if (error) return <div>Error: {error.message}</div>; * * return ( * <paypal-credit-button * countryCode={creditDetails?.countryCode} * onClick={handleClick} * onCancel={handleCancel} * /> * ); * } */ function usePayPalCreditSavePaymentSession({ presentationMode, fullPageOverlay, autoRedirect, createVaultToken, vaultSetupToken, ...callbacks }) { const { sdkInstance, loadingStatus } = usePayPal(); const isMountedRef = useIsMountedRef(); const sessionRef = useRef(null); const proxyCallbacks = useProxyProps(callbacks); const [error, setError] = useError(); // Prevents retrying session creation with a failed SDK instance const failedSdkRef = useRef(null); const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING; const handleDestroy = useCallback(() => { sessionRef.current?.destroy(); sessionRef.current = null; }, []); // Handle SDK availability useEffect(() => { // Reset failed SDK tracking when SDK instance changes if (failedSdkRef.current !== sdkInstance) { failedSdkRef.current = null; } if (sdkInstance) { setError(null); } else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) { setError(new Error("no sdk instance available")); } }, [sdkInstance, setError, loadingStatus]); // Create and manage session lifecycle useEffect(() => { if (!sdkInstance) { return; } const newSession = createPaymentSession({ sessionCreator: () => sdkInstance.createPayPalSavePaymentSession({ vaultSetupToken, ...proxyCallbacks }), failedSdkRef, sdkInstance, setError, errorMessage: 'Failed to create payment session. This may occur if the required component "paypal-payments" is not included in the SDK components array.' }); if (!newSession) { return; } sessionRef.current = newSession; const shouldCheckResume = presentationMode === "redirect" || presentationMode === "direct-app-switch"; if (shouldCheckResume) { const handleReturnFromPayPal = async () => { try { if (!newSession) { return; } const isResumeFlow = newSession.hasReturned?.(); if (isResumeFlow) { await newSession.resume?.(); } } catch (err) { setError(err); } }; handleReturnFromPayPal(); } return () => { newSession.destroy(); }; }, [sdkInstance, vaultSetupToken, proxyCallbacks, presentationMode, setError]); const handleCancel = useCallback(() => { sessionRef.current?.cancel(); }, []); const handleClick = useCallback(async () => { if (!isMountedRef.current) { return; } if (!sessionRef.current) { setError(new Error("Credit Save Payment session not available")); return; } const startOptions = { presentationMode, fullPageOverlay, autoRedirect }; if (createVaultToken) { await sessionRef.current.start(startOptions, createVaultToken()); } else { await sessionRef.current.start(startOptions); } }, [isMountedRef, presentationMode, fullPageOverlay, autoRedirect, createVaultToken, setError]); return { error, isPending, handleClick, handleDestroy, handleCancel }; } /** * `PayPalCreditSavePaymentButton` is a button that provides a PayPal Credit save payment flow. * * The `countryCode` is automatically populated from the eligibility API response * (available via `usePayPal().eligiblePaymentMethods`). The button requires eligibility to be configured * in the parent `PayPalProvider`, using either the `useEligibleMethods` hook client-side or `useFetchEligibleMethods` server-side. * * Note, `autoRedirect` is not allowed because if given a `presentationMode` of `"redirect"` the button * would not be able to provide back `redirectURL` from `start`. Advanced integrations that need * `redirectURL` should use the {@link usePayPalCreditSavePaymentSession} hook directly. * * @example * <PayPalCreditSavePaymentButton * onApprove={() => { * // ... on approve logic * }} * vaultSetupToken="your-vault-setup-token" * presentationMode="auto" * /> */ const PayPalCreditSavePaymentButton = ({ disabled = false, ...hookProps }) => { const { eligiblePaymentMethods, isHydrated } = usePayPal(); const { error, isPending, handleClick } = usePayPalCreditSavePaymentSession(hookProps); const creditDetails = eligiblePaymentMethods?.getDetails("credit"); const countryCode = creditDetails?.countryCode; useEffect(() => { if (error) { console.error(error); } }, [error]); if (isPending) { return null; } return isHydrated ? React.createElement("paypal-credit-button", { onClick: handleClick, countryCode: countryCode, disabled: disabled || !!error || undefined }) : React.createElement("div", null); }; /** * `usePayPalGuestPaymentSession` is used to interface with a guest checkout session. Guest checkout * sessions require a `<paypal-basic-card-button>` to target for displaying the guest checkout form. * * @returns Object with: `buttonRef` (ref for the target button element), `error` (any session error), `isPending` (SDK loading), `handleClick` (starts session), `handleCancel` (cancels session), `handleDestroy` (cleanup) * * @example * function GuestCheckoutButton() { * const { buttonRef, error, isPending, handleClick, handleCancel } = * usePayPalGuestPaymentSession({ * createOrder: async () => ({ orderId: 'ORDER-123' }), * onApprove: (data) => console.log('Approved:', data), * onCancel: () => console.log('Cancelled'), * }); * * if (isPending) return null; * if (error) return <div>Error: {error.message}</div>; * * return ( * <paypal-basic-card-container> * <paypal-basic-card-button * onClick={handleClick} * onCancel={handleCancel} * ref={buttonRef} * /> * </paypal-basic-card-container> * ); * } */ function usePayPalGuestPaymentSession({ fullPageOverlay, createOrder, orderId, onShippingAddressChange, onShippingOptionsChange, ...callbacks }) { const { sdkInstance, loadingStatus } = usePayPal(); const isMountedRef = useIsMountedRef(); const sessionRef = useRef(null); const buttonRef = useRef(null); const proxyCallbacks = useProxyProps(callbacks); const [error, setError] = useError(); // Prevents retrying session creation with a failed SDK instance const failedSdkRef = useRef(null); const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING; const handleDestroy = useCallback(() => { sessionRef.current?.destroy(); sessionRef.current = null; }, []); const handleCancel = useCallback(() => { sessionRef.current?.cancel(); }, []); // Handle SDK availability useEffect(() => { // Reset failed SDK tracking when SDK instance changes if (failedSdkRef.current !== sdkInstance) { failedSdkRef.current = null; } if (sdkInstance) { setError(null); } else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) { setError(new Error("no sdk instance available")); } }, [sdkInstance, setError, loadingStatus]); // Create and manage session lifecycle useEffect(() => { if (!sdkInstance) { return; } const newSession = createPaymentSession({ sessionCreator: () => sdkInstance.createPayPalGuestOneTimePaymentSession({ orderId, ...proxyCallbacks, ...(onShippingAddressChange && { onShippingAddressChange }), ...(onShippingOptionsChange && { onShippingOptionsChange }) }), failedSdkRef, sdkInstance, setError, errorMessage: 'Failed to create payment session. This may occur if the required component "paypal-guest-payments" is not included in the SDK components array.' }); if (!newSession) { return; } sessionRef.current = newSession; return () => { newSession.destroy(); }; }, [sdkInstance, orderId, proxyCallbacks, onShippingAddressChange, onShippingOptionsChange, isMountedRef, setError]); const handleClick = useCallback(async () => { if (!isMountedRef.current) { return; } if (!sessionRef.current) { setError(new Error("PayPal Guest Checkout session not available")); return; } try { const startOptions = { presentationMode: "auto", fullPageOverlay, ...(buttonRef.current ? { targetElement: buttonRef.current } : {}) }; await sessionRef.current.start(startOptions, createOrder?.()); } catch (err) { if (isMountedRef.current) { setError(err); } } }, [isMountedRef, fullPageOverlay, createOrder, setError]); return { buttonRef, error, isPending, handleClick, handleCancel, handleDestroy }; } /** * `PayPalGuestPaymentButton` is a button that provides a guest checkout (BCDC) payment flow. * * `PayPalGuestPaymentButtonProps` combines the arguments for {@link UsePayPalGuestPaymentSessionProps} * with a `disabled` prop. * * This component automatically wraps the button with `<paypal-basic-card-container>` which is * required for the guest checkout form to display properly. * * @example * <PayPalGuestPaymentButton * createOrder={createOrder} * onApprove={() => { * // ... on approve logic * }} * /> */ const PayPalGuestPaymentButton = ({ disabled = false, ...hookProps }) => { const { error, isPending, handleClick, buttonRef } = usePayPalGuestPaymentSession(hookProps); const { isHydrated } = usePayPal(); useEffect(() => { if (error) { console.error(error); } }, [error]); const button = isHydrated ? React.createElement("paypal-basic-card-button", { ref: buttonRef, onClick: handleClick, disabled: disabled || isPending || error !== null ? true : undefined }) : React.createElement("div", null); return React.createElement("paypal-basic-card-container", null, button); }; /** * Hook for managing one-time payment sessions with PayPal. * * The hook returns an `isPending` flag that indicates whether the SDK instance is still being * initialized. This is useful when using deferred clientToken loading - buttons should wait * to render until `isPending` is false. * * @returns Object with: `error` (any session error), `isPending` (SDK loading), `handleClick` (starts session), `handleCancel` (cancels session), `handleDestroy` (cleanup) * * @example * function PayPalCheckoutButton() { * const { isPending, error, handleClick, handleCancel } = usePayPalOneTimePaymentSession({ * orderId: "ORDER-123", * presentationMode: "auto", * onApprove: (data) => console.log("Approved:", data), * }); * * if (isPending) return null; * if (error) return <div>Error: {error.message}</div>; * * return ( * <paypal-button onClick={handleClick} onCancel={handleCancel} /> * ); * } */ function usePayPalOneTimePaymentSession({ presentationMode, fullPageOverlay, autoRedirect, createOrder, orderId, savePayment, testBuyerCountry, ...callbacks }) { const { sdkInstance, loadingStatus } = usePayPal(); const isMountedRef = useIsMountedRef(); const sessionRef = useRef(null); const proxyCallbacks = useProxyProps(callbacks); const [error, setError] = useError(); // Prevents retrying session creation with a failed SDK instance const failedSdkRef = useRef(null); const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING; const handleDestroy = useCallback(() => { sessionRef.current?.destroy(); sessionRef.current = null; }, []); const handleCancel = useCallback(() => { sessionRef.current?.cancel(); }, []); // Handle SDK availability useEffect(() => { // Reset failed SDK tracking when SDK instance changes if (failedSdkRef.current !== sdkInstance) { failedSdkRef.current = null; } if (sdkInstance) { setError(null); } else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) { setError(new Error("no sdk instance available")); } }, [sdkInstance, setError, loadingStatus]); // Create and manage session lifecycle useEffect(() => { if (!sdkInstance) { return; } const newSession = createPaymentSession({ sessionCreator: () => sdkInstance.createPayPalOneTimePaymentSession({ orderId, savePayment, testBuyerCountry, ...proxyCallbacks }), failedSdkRef, sdkInstance, setError, errorMessage: 'Failed to create payment session. This may occur if the required component "paypal-payments" is not included in the SDK components array.' }); if (!newSession) { return; } sessionRef.current = newSession; // Only check for resume flow in redirect-based presentation modes const shouldCheckResume = presentationMode === "redirect" || presentationMode === "direct-app-switch"; if (shouldCheckResume) { const handleReturnFromPayPal = async () => { try { if (!newSession) { return; } const isResumeFlow = newSession.hasReturned?.(); if (isResumeFlow) { await newSession.resume?.(); } } catch (err) { setError(err); } }; handleReturnFromPayPal(); } return () => { newSession.destroy(); }; }, [sdkInstance, orderId, proxyCallbacks, presentationMode, setError, savePayment, testBuyerCountry]); const handleClick = useCallback(async () => { if (!isMountedRef.current) { return; } if (!sessionRef.current) { setError(new Error("PayPal session not available")); return; } const startOptions = { presentationMode, fullPageOverlay, autoRedirect }; const result = await sessionRef.current.start(startOptions, createOrder?.()); return result; }, [isMountedRef, presentationMode, fullPageOverlay, autoRedirect, createOrder, setError]); return { error, isPending, handleClick, handleCancel, handleDestroy }; } /** * `PayPalOneTimePaymentButton` is a button that provides a standard PayPal payment flow. * * `PayPalOneTimePaymentButtonProps` combines the arguments for {@link UsePayPalOneTimePaymentSessionProps} * and {@link ButtonProps}. * * Note, `autoRedirect` is not allowed because if given a `presentationMode` of `"redirect"` the button * would not be able to provide back `redirectURL` from `start`. Advanced integrations that need * `redirectURL` should use the {@link usePayPalOneTimePaymentSession} hook directly. * * @example * <PayPalOneTimePaymentButton * onApprove={() => { * // ... on approve logic * }} * orderId="your-order-id" * presentationMode="auto" * /> */ const PayPalOneTimePaymentButton = ({ type = "pay", disabled = false, ...hookProps }) => { const { error, isPending, handleClick } = usePayPalOneTimePaymentSession(hookProps); const { isHydrated } = usePayPal(); useEffect(() => { if (error) { console.error(error); } }, [error]); return isHydrated ? React.createElement("paypal-button", { onClick: handleClick, type: type, disabled: disabled || isPending || error !== null ? true : undefined }) : React.createElement("div", null); }; /** * Hook for managing PayPal subscription payment sessions. * * This hook creates and manages a PayPal subscription payment session, supporting multiple presentation modes * including popup and modal. It handles session lifecycle and provides methods to start, cancel, and destroy the session. * * @param props - Configuration options including presentation mode and callbacks * @param props.createSubscription - Function that returns a promise resolving to an object with subscriptionId * @param props.presentationMode - How the subscription experience is presented: 'popup', 'modal', 'auto', or 'payment-handler' * @param props.fullPageOverlay - Whether to show a full-page overlay during the subscription flow * @returns Object with: `error` (any session error), `isPending` (SDK loading), `handleClick` (starts session), `handleCancel` (cancels session), `handleDestroy` (cleanup) * * @example * function SubscriptionCheckoutButton() { * const { error, isPending, handleClick, handleCancel } = usePayPalSubscriptionPaymentSession({ * presentationMode: 'popup', * createSubscription: async () => ({ subscriptionId: 'SUB-123' }), * onApprove: (data) => console.log('Subscription approved:', data), * onCancel: () => console.log('Subscription cancelled'), * onError: (err) => console.error('Subscription error:', err), * }); * * if (isPending) return null; * if (error) return <div>Error: {error.message}</div>; * * return ( * <paypal-button onClick={handleClick} onCancel={handleCancel} /> * ); * } */ function usePayPalSubscriptionPaymentSession({ presentationMode, fullPageOverlay, createSubscription, ...callbacks }) { const { sdkInstance, loadingStatus } = usePayPal(); const isMountedRef = useIsMountedRef(); const sessionRef = useRef(null); const proxyCallbacks = useProxyProps(callbacks); const [error, setError] = useError(); // Prevents retrying session creation with a failed SDK instance const failedSdkRef = useRef(null); const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING; const handleDestroy = useCallback(() => { sessionRef.current?.destroy(); sessionRef.current = null; }, []); const handleCancel = useCallback(() => { sessionRef.current?.cancel(); }, []); // Handle SDK availability useEffect(() => { // Reset failed SDK tracking when SDK instance changes if (failedSdkRef.current !== sdkInstance) { failedSdkRef.current = null; } if (sdkInstance) { setError(null); } else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) { setError(new Error("no sdk instance available")); } }, [sdkInstance, setError, loadingStatus]); // Create and manage session lifecycle useEffect(() => { if (!sdkInstance) { return; } const newSession = createPaymentSession({ sessionCreator: () => sdkInstance.createPayPalSubscriptionPaymentSession({ ...proxyCallbacks }), failedSdkRef, sdkInstance, setError, errorMessage: 'Failed to create payment session. This may occur if the required component "paypal-subscriptions" is not included in the SDK components array.' }); if (!newSession) { return; } sessionRef.current = newSession; return () => { newSession.destroy(); }; }, [sdkInstance, proxyCallbacks, setError]); const handleClick = useCallback(async () => { if (!isMountedRef.current) { return; } if (!sessionRef.current) {