/** * PayPal Payment Integration - Radio-Based Payment Method Selector. * * Architecture: * - A radio group lets the user pick their payment method (Card, PayPal, Venmo, etc.). * - Only the selected method's UI is visible at a time. * - Card + PayPal are pre-rendered on init (hybrid approach). * - Other methods (Venmo, Google Pay, etc.) are lazy-rendered on first selection, then cached. * - When Card is selected: card fields + native submit button are shown. * - When any button method is selected: the submit button is hidden and only that button is shown. */ ( function() { if ( ! window.frmPayPalVars ) { return; } // ---- State ---- let thisForm = null; let running = 0; let cardFieldsInstance = null; let cardFieldsValid = false; let submitEvent = null; let isRecurring = false; /** * Registry of available payment methods. * Populated during init based on SDK eligibility checks. * * @type {Map} */ const paymentMethods = new Map(); /** Currently selected payment method key. */ let selectedMethod = null; /** Cached Google Pay config from paypal.Googlepay().config(). */ let googlePayConfig = null; /** Cached Apple Pay config from paypal.Applepay().config(). */ let applePayConfig = null; /** Cached Apple Pay SDK instance (reused for validateMerchant and confirmOrder). */ let applePayInstance = null; /** Cached payment amount for Apple Pay (must be available synchronously in click handler). */ let cachedAmount = '0.00'; // ---- Constants ---- /** * Human-readable labels for funding sources. */ const METHOD_LABELS = { card: 'Credit Card', paypal: 'PayPal', venmo: 'Venmo', paylater: 'Pay Later', google_pay: 'Google Pay', apple_pay: 'Apple Pay', bancontact: 'Bancontact', blik: 'BLIK', eps: 'EPS', p24: 'Przelewy24', trustly: 'Trustly', satispay: 'Satispay', sepa: 'SEPA', mybank: 'MyBank', ideal: 'iDEAL', }; /** * Maps internal method keys to PayPal FUNDING constants for the Marks API. * Card and Google Pay use local images instead of PayPal Marks. */ const METHOD_FUNDING_SOURCE = { paypal: 'paypal', venmo: 'venmo', paylater: 'paylater', bancontact: 'bancontact', blik: 'blik', eps: 'eps', p24: 'p24', trustly: 'trustly', satispay: 'satispay', sepa: 'sepa', mybank: 'mybank', ideal: 'ideal', }; /** * Methods that should be pre-rendered on init (hybrid approach). * Everything else is lazy-rendered on first selection. */ const PRE_RENDER_METHODS = new Set( [ 'card', 'paypal' ] ); /** * Base request object shared by isReadyToPay and PaymentDataRequest. */ const googlePayBaseRequest = { apiVersion: 2, apiVersionMinor: 0 }; // ---- Initialization ---- /** * Main entry point. */ async function paypalInit() { const cardElement = document.querySelector( '.frm-card-element' ); if ( ! cardElement ) { return; } thisForm = cardElement.closest( 'form' ); if ( ! thisForm ) { return; } const settings = getPayPalSettings()[ 0 ]; if ( ! settings ) { return; } isRecurring = 'single' !== settings.one; const { paypalLayout: layout } = settings; const cardFieldsAreSupported = layout !== 'checkout_only' && 'function' === typeof window.paypal.CardFields; const buttonsAreEnabled = layout !== 'card_only' && 'function' === typeof window.paypal.Buttons; // Clear the card element. We rebuild it entirely. cardElement.innerHTML = ''; // Reset state so each run rediscovers cleanly (the DOM above was cleared). paymentMethods.clear(); selectedMethod = null; // 1. Discover synchronous methods (Card, PayPal, alternative funding). await discoverPaymentMethods( { cardFieldsAreSupported, buttonsAreEnabled, isRecurring } ); // 1b. Discover Google Pay / Apple Pay. Their SDKs (pay.js / PayPal applepay // component) load asynchronously and are frequently not ready during the first // run, which previously left these buttons missing on first load. We wait // (bounded) for them here so every eligible method appears together in one // render instead of popping in afterward. await discoverDeferredPaymentMethods( { buttonsAreEnabled, isRecurring } ); if ( paymentMethods.size === 0 ) { displayPaymentFailure( 'No payment methods available.' ); return; } // 2. Build the radio selector UI, then render marks after it's in the DOM. // Hide the radio group if there's only one payment method available. const radioGroup = buildRadioGroup(); if ( paymentMethods.size === 1 ) { radioGroup.style.display = 'none'; } cardElement.append( radioGroup ); renderMarks(); // 3. Build a container area for each method's UI (buttons / card fields). const methodArea = document.createElement( 'div' ); methodArea.classList.add( 'frm-payment-method-area' ); cardElement.append( methodArea ); for ( const [ key, method ] of paymentMethods ) { const container = document.createElement( 'div' ); container.id = `frm-payment-method-${ key }`; container.classList.add( 'frm-payment-method-container' ); methodArea.append( container ); method.containerEl = container; } // 4. Pre-render Card + PayPal (hybrid approach). for ( const key of PRE_RENDER_METHODS ) { const method = paymentMethods.get( key ); if ( method?.eligible ) { try { await method.render(); method.rendered = true; } catch ( err ) { console.error( `Failed to pre-render payment method: ${ key }`, err ); } } } // 5. Auto-select the first eligible method. const firstKey = paymentMethods.keys().next().value; await selectPaymentMethod( firstKey ); // 6. Attach form submit handler (for card method). thisForm.addEventListener( 'submit', handleFormSubmission ); // 7. Pay Later messages. if ( paymentMethods.has( 'paylater' ) ) { renderMessages(); jQuery( document ).on( 'frmFieldChanged', priceChanged ); checkPriceFieldsOnLoad(); } // 8. Pre-fetch the amount for Apple Pay so it is available synchronously in the click handler. if ( paymentMethods.has( 'apple_pay' ) ) { refreshCachedAmount(); if ( ! paymentMethods.has( 'paylater' ) ) { jQuery( document ).on( 'frmFieldChanged', refreshCachedAmountOnFieldChange ); } } } // ---- Discovery ---- /** * Discover which payment methods are eligible and register them. * * @param {Object} opts Config flags. */ async function discoverPaymentMethods( opts ) { const { cardFieldsAreSupported, buttonsAreEnabled, isRecurring } = opts; // --- Card Fields --- // Card fields are not supported for recurring payments if ( cardFieldsAreSupported && ! isRecurring ) { const cardFields = createCardFieldsSDKInstance(); if ( cardFields?.isEligible() ) { cardFieldsInstance = cardFields; registerMethod( 'card', { eligible: true, render: renderCardFields } ); } } // --- PayPal button --- if ( buttonsAreEnabled ) { const paypalBtn = createPayPalButton( paypal.FUNDING.PAYPAL, isRecurring ); if ( paypalBtn.isEligible() ) { registerMethod( 'paypal', { eligible: true, buttonInstance: paypalBtn, render() { this.buttonInstance.render( `#${ this.containerEl.id }` ); } } ); } } // --- Alternative funding sources --- if ( buttonsAreEnabled ) { const fundingSources = [ { key: 'venmo', funding: paypal.FUNDING.VENMO }, { key: 'paylater', funding: paypal.FUNDING.PAYLATER }, { key: 'bancontact', funding: paypal.FUNDING.BANCONTACT }, { key: 'blik', funding: paypal.FUNDING.BLIK }, { key: 'eps', funding: paypal.FUNDING.EPS }, { key: 'p24', funding: paypal.FUNDING.P24 }, { key: 'trustly', funding: paypal.FUNDING.TRUSTLY }, { key: 'satispay', funding: paypal.FUNDING.SATISPAY }, { key: 'sepa', funding: paypal.FUNDING.SEPA }, { key: 'mybank', funding: paypal.FUNDING.MYBANK }, { key: 'ideal', funding: paypal.FUNDING.IDEAL }, ]; for ( const { key, funding } of fundingSources ) { const btn = createPayPalButton( funding, isRecurring ); if ( btn.isEligible() ) { registerMethod( key, { eligible: true, buttonInstance: btn, render() { this.buttonInstance.render( `#${ this.containerEl.id }` ); } } ); } } } // Google Pay and Apple Pay are discovered separately in // discoverDeferredPaymentMethods() because their SDKs (pay.js / PayPal applepay // component) may not be ready yet when this runs. } /** * Discover and register Google Pay and Apple Pay. * * Their SDKs load asynchronously and are frequently not ready during the first * paypalInit() run, which previously caused these buttons to be missing on first * load and only appear after a page change. We wait (bounded) for each SDK and * register the eligible methods so they are included when the selector is built. * * @param {Object} opts Config flags. * * @return {Promise} */ async function discoverDeferredPaymentMethods( opts ) { const { buttonsAreEnabled, isRecurring } = opts; if ( ! buttonsAreEnabled || isRecurring ) { return; } // Skip (and avoid the SDK wait) when neither wallet was enqueued server-side, // e.g. via the frm_include_google_pay_apple_pay filter or non-SSL. if ( ! frmPayPalVars.includeGooglePay && ! frmPayPalVars.includeApplePay ) { return; } // Resolve both eligibility checks in parallel so the combined wait is bounded by // the slower of the two, then register them in a fixed order (Google Pay, then // Apple Pay). Registration happens before the selector is built, so they render // together with the other methods. A wallet turned off server-side, e.g. via // frm_paypal_commerce_include_google_pay or frm_paypal_commerce_include_apple_pay, // skips its check so it never waits on an SDK that was never enqueued. const [ googlePayEligible, applePayEligible ] = await Promise.all( [ frmPayPalVars.includeGooglePay ? resolveGooglePayEligibility() : false, frmPayPalVars.includeApplePay ? resolveApplePayEligibility() : false ] ); if ( googlePayEligible ) { registerMethod( 'google_pay', { eligible: true, render: renderGooglePayButton } ); } if ( applePayEligible ) { registerMethod( 'apple_pay', { eligible: true, render: renderApplePayButton } ); } } /** * Wait for the Google Pay SDK and resolve whether Google Pay is eligible. * * @return {Promise} Whether Google Pay is eligible. */ async function resolveGooglePayEligibility() { const sdkReady = await waitFor( () => 'function' === typeof paypal.Googlepay && 'undefined' !== typeof google && undefined !== google.payments ); if ( ! sdkReady ) { return false; } return checkGooglePayEligibility(); } /** * Wait for the Apple Pay SDK and resolve whether Apple Pay is eligible. * * @return {Promise} Whether Apple Pay is eligible. */ async function resolveApplePayEligibility() { // Wait for Apple Pay session to be available (requires Apple Pay SDK to load). const sessionReady = await waitFor( () => undefined !== window.ApplePaySession ); if ( ! sessionReady ) { return false; } // Wait for canMakePayments to return true. const canMakePaymentsReady = await waitFor( () => ApplePaySession.canMakePayments() ); if ( ! canMakePaymentsReady ) { return false; } // First ensure the PayPal SDK itself is loaded. const paypalReady = await waitFor( () => 'object' === typeof window.paypal ); if ( ! paypalReady ) { return false; } // Wait for the Apple Pay function to be available. const sdkReady = await waitFor( () => 'function' === typeof paypal.Applepay ); if ( ! sdkReady ) { return false; } // Wait for the config call to actually succeed, not just the function to exist. // This handles the race condition where the function exists but SDK isn't fully initialized. const configReady = await waitFor( async () => { try { const instance = paypal.Applepay(); const config = await instance.config(); return config?.isEligible; } catch ( e ) { return false; } } ); if ( ! configReady ) { return false; } // Config succeeded, cache it and return true. applePayInstance = paypal.Applepay(); applePayConfig = await applePayInstance.config(); return true; } /** * Poll for a condition until it is true or a timeout elapses. * * @param {Function} predicate Returns true (or a Promise resolving to true) when the awaited dependency is ready. * @param {number} [timeout] Maximum time to wait, in milliseconds. * @param {number} [interval] Poll interval, in milliseconds. * * @return {Promise} Whether the predicate became true before the timeout. */ async function waitFor( predicate, timeout = 3000, interval = 50 ) { const checkPredicate = async () => { const result = predicate(); if ( result instanceof Promise ) { return await result; } return result; }; if ( await checkPredicate() ) { return true; } const start = Date.now(); return new Promise( resolve => { const timer = setInterval( async () => { if ( await checkPredicate() ) { clearInterval( timer ); resolve( true ); } else if ( Date.now() - start >= timeout ) { clearInterval( timer ); resolve( false ); } }, interval ); } ); } /** * Register a payment method in the registry. * * @param {string} key Unique identifier. * @param {Object} config Method configuration. */ function registerMethod( key, config ) { paymentMethods.set( key, { key, label: METHOD_LABELS[ key ] || key, eligible: config.eligible || false, rendered: false, containerEl: null, buttonInstance: config.buttonInstance || null, render: config.render || ( () => {} ), } ); } // ---- Radio Group UI ---- /** * Build the radio button group for payment method selection. * Each option is a card-like row with a radio, label, description, and PayPal Mark logo. * * @return {HTMLElement} The radio group container. */ function buildRadioGroup() { const group = document.createElement( 'div' ); group.classList.add( 'frm-payment-method-selector' ); group.setAttribute( 'role', 'radiogroup' ); group.setAttribute( 'aria-label', 'Select payment method' ); for ( const [ key, method ] of paymentMethods ) { group.append( buildMethodOption( key, method ) ); } return group; } /** * Build a single payment method radio option row. * * @param {string} key The payment method key. * @param {Object} method The registered method object. * * @return {HTMLElement} The option element (a label, or a wrapper for Pay Later). */ function buildMethodOption( key, method ) { const label = document.createElement( 'label' ); label.classList.add( 'frm-payment-method-option' ); label.setAttribute( 'for', `frm-payment-method-radio-${ key }` ); const radio = document.createElement( 'input' ); radio.type = 'radio'; radio.name = 'frm_payment_method'; radio.id = `frm-payment-method-radio-${ key }`; radio.value = key; radio.addEventListener( 'change', () => selectPaymentMethod( key ) ); // Text column: label + description. const textWrap = document.createElement( 'div' ); textWrap.classList.add( 'frm-payment-method-text' ); const labelText = document.createElement( 'span' ); labelText.classList.add( 'frm-payment-method-label-text' ); labelText.textContent = method.label; textWrap.append( labelText ); // Mark column: will be populated by renderMarks() after the group is in the DOM. const markWrap = document.createElement( 'div' ); markWrap.classList.add( 'frm-payment-method-mark' ); markWrap.id = `frm-payment-mark-${ key }`; const baseUrl = frmPayPalVars.imagesUrl || ''; if ( key === 'card' ) { const cardBrands = [ { file: 'visa.svg', alt: 'Visa' }, { file: 'mastercard.svg', alt: 'Mastercard' }, { file: 'amex.svg', alt: 'American Express' }, { file: 'discover.svg', alt: 'Discover' }, ]; cardBrands.forEach( function( brand ) { const img = document.createElement( 'img' ); img.src = baseUrl + brand.file; img.alt = brand.alt; img.height = 24; markWrap.append( img ); } ); } else if ( key === 'google_pay' ) { markWrap.classList.add( 'frm-payment-method-google-pay-icon' ); const img = document.createElement( 'img' ); img.src = `${ baseUrl }gpay.svg`; img.alt = 'Google Pay'; img.height = 24; markWrap.append( img ); } else if ( key === 'apple_pay' ) { markWrap.classList.add( 'frm-payment-method-apple-pay-icon' ); const img = document.createElement( 'img' ); img.src = `${ baseUrl }apple-pay.svg`; img.alt = 'Apple Pay'; img.height = 24; img.style.width = 'auto'; markWrap.append( img ); } label.append( radio ); label.append( textWrap ); label.append( markWrap ); if ( key === 'paylater' ) { // Wrap the label and a message container in a div. const wrapper = document.createElement( 'div' ); wrapper.classList.add( 'frm-payment-method-paylater-wrap' ); wrapper.append( label ); const msgContainer = document.createElement( 'div' ); msgContainer.id = 'frm-paylater-message'; msgContainer.classList.add( 'frm-payment-method-paylater-msg' ); wrapper.append( msgContainer ); return wrapper; } return label; } /** * Render PayPal Marks into the radio group containers. * Must be called AFTER the radio group is appended to the DOM, * because the Marks API needs the containers to be in the document. */ function renderMarks() { if ( 'function' !== typeof paypal.Marks ) { return; } for ( const [ key ] of paymentMethods ) { const fundingSource = METHOD_FUNDING_SOURCE[ key ]; if ( ! fundingSource ) { continue; } const markContainerId = `frm-payment-mark-${ key }`; const container = document.getElementById( markContainerId ); if ( ! container ) { continue; } try { const mark = paypal.Marks( { fundingSource } ); if ( mark.isEligible() ) { mark.render( `#${ markContainerId }` ); } } catch ( err ) { // Mark not available for this source, that's fine. } } } // ---- Method Selection ---- /** * Handle switching to a new payment method. * * 1. Lazy-render if this method hasn't been rendered yet. * 2. Hide all method containers. * 3. Show the selected method's container. * 4. Toggle submit button visibility. * * @param {string} key The payment method key to select. */ async function selectPaymentMethod( key ) { const method = paymentMethods.get( key ); if ( ! method ) { return; } selectedMethod = key; // Update radio checked state. const radio = document.getElementById( `frm-payment-method-radio-${ key }` ); if ( radio && ! radio.checked ) { radio.checked = true; } // Lazy-render if this is the first time selecting a non-pre-rendered method. if ( ! method.rendered ) { method.containerEl.innerHTML = ''; try { await method.render(); method.rendered = true; } catch ( err ) { console.error( `Failed to render payment method: ${ key }`, err ); method.containerEl.innerHTML = ''; } } // Hide all method containers. for ( const [ , m ] of paymentMethods ) { if ( m.containerEl ) { m.containerEl.style.display = 'none'; } } // Show the selected one. if ( method.containerEl ) { method.containerEl.style.display = 'block'; } // Toggle submit button + card fields visibility. updateSubmitButtonVisibility( key ); // Update active class on radio labels. document.querySelectorAll( '.frm-payment-method-option' ).forEach( el => { el.classList.remove( 'frm-payment-method-active' ); } ); document.querySelectorAll( '.frm-payment-method-paylater-wrap' ).forEach( el => { el.classList.remove( 'frm-payment-method-active-wrap' ); } ); const activeLabel = radio?.closest( '.frm-payment-method-option' ); if ( activeLabel ) { activeLabel.classList.add( 'frm-payment-method-active' ); const wrapper = activeLabel.closest( '.frm-payment-method-paylater-wrap' ); if ( wrapper ) { wrapper.classList.add( 'frm-payment-method-active-wrap' ); } } } /** * Show/hide the native submit button based on the selected method. * * - Card: submit button visible (user fills card fields, clicks submit). * - Everything else: submit button hidden (PayPal SDK button handles submission). * * @param {string} key The selected payment method key. */ function updateSubmitButtonVisibility( key ) { const submitButtons = thisForm.querySelectorAll( 'input[type="submit"], input[type="button"], button[type="submit"]' ); const isCardMethod = key === 'card'; submitButtons.forEach( btn => { if ( btn.classList.contains( 'frm_prev_page' ) ) { return; } if ( isCardMethod ) { btn.style.display = ''; if ( cardFieldsValid ) { btn.removeAttribute( 'disabled' ); } else { btn.setAttribute( 'disabled', 'disabled' ); } } else { btn.style.display = 'none'; } } ); } // ---- Card Fields ---- /** * Create the PayPal CardFields SDK instance (without rendering). * * @return {Object|null} The card fields instance. */ function createCardFieldsSDKInstance() { if ( isRecurring ) { // Credit cards are only supported for one time payments. return null; } try { const config = { onError, style: frmPayPalVars.style, inputEvents: { onChange: onCardFieldsChange } }; config.createOrder = createOrder; config.onApprove = onApprove; return window.paypal.CardFields( config ); } catch ( err ) { console.error( 'Failed to create CardFields instance', err ); return null; } } /** * Handle card field value changes. * * @param {Object} data The onChange event data. */ function onCardFieldsChange( data ) { cardFieldsValid = data.isFormValid; if ( selectedMethod === 'card' ) { if ( cardFieldsValid ) { enableSubmit(); } else { disableSubmit( thisForm ); } } } /** * Render the card number / expiry / CVV fields into the method container. */ function renderCardFields() { const method = paymentMethods.get( 'card' ); if ( ! method || ! cardFieldsInstance ) { return; } const wrapper = document.createElement( 'div' ); wrapper.classList.add( 'frm-card-fields-wrapper', 'frm_grid_container' ); const cardNumberWrapper = document.createElement( 'div' ); cardNumberWrapper.id = 'frm-paypal-card-number'; cardNumberWrapper.classList.add( 'frm6', 'frm-payment-card-number' ); const expiryWrapper = document.createElement( 'div' ); expiryWrapper.id = 'frm-paypal-card-expiry'; expiryWrapper.classList.add( 'frm3', 'frm-payment-card-expiry' ); const cvvWrapper = document.createElement( 'div' ); cvvWrapper.id = 'frm-paypal-card-cvv'; cvvWrapper.classList.add( 'frm3', 'frm-payment-card-cvv' ); wrapper.append( cardNumberWrapper, expiryWrapper, cvvWrapper ); method.containerEl.innerHTML = ''; method.containerEl.append( wrapper ); cardFieldsInstance.NumberField().render( '#frm-paypal-card-number' ); cardFieldsInstance.ExpiryField().render( '#frm-paypal-card-expiry' ); cardFieldsInstance.CVVField().render( '#frm-paypal-card-cvv' ); setupCardFieldIframeObservers(); } /** * Watch for PayPal iframe height changes and add 1px to prevent border clipping. */ function setupCardFieldIframeObservers() { const ids = [ 'frm-paypal-card-number', 'frm-paypal-card-expiry', 'frm-paypal-card-cvv' ]; const wrappers = ids .map( id => document.getElementById( id )?.querySelector( 'iframe' )?.parentNode ) .filter( Boolean ); if ( ! wrappers.length ) { return; } const observerOptions = { attributes: true, attributeFilter: [ 'style' ] }; const observerCallback = mutationsList => { for ( const mutation of mutationsList ) { if ( mutation.type !== 'attributes' || mutation.attributeName !== 'style' ) { continue; } const currentHeight = mutation.target.offsetHeight; // Only adjust if height is reasonable and not already adjusted. if ( currentHeight > 40 && currentHeight < 100 ) { mutation.target.style.height = `${ currentHeight + 1 }px`; } } }; const observer = new MutationObserver( observerCallback ); wrappers.forEach( w => observer.observe( w, observerOptions ) ); } // ---- PayPal Button Creation ---- /** * Create a PayPal Buttons instance for a given funding source (without rendering). * * @param {string} fundingSource The PayPal FUNDING constant. * @param {boolean} isRecurring Whether this is a recurring payment. * * @return {Object} The PayPal Buttons instance. */ function createPayPalButton( fundingSource, isRecurring ) { const buttonConfig = { fundingSource, onApprove, onError, onCancel, style: { ...frmPayPalVars.buttonStyle }, }; const supportedColors = [ 'silver', 'black', 'white' ]; const supportedColorsMap = { venmo: [ 'blue' ], paylater: [ 'gold', 'blue' ] }; supportedColorsMap[ fundingSource ]?.forEach( color => supportedColors.push( color ) ); if ( ! supportedColors.includes( buttonConfig.style.color ) ) { delete buttonConfig.style.color; } if ( isRecurring ) { buttonConfig.createSubscription = createSubscription; } else { buttonConfig.createOrder = createOrder; } return paypal.Buttons( buttonConfig ); } // ---- Google Pay ---- /** * Check if Google Pay is eligible (without rendering). * * @return {Promise} Whether Google Pay is supported and ready to accept payments in the current environment. */ async function checkGooglePayEligibility() { if ( 'function' !== typeof paypal.Googlepay ) { return false; } if ( 'undefined' === typeof google || google.payments === undefined ) { return false; } try { googlePayConfig = await paypal.Googlepay().config(); const paymentsClient = getGooglePaymentsClient(); const readyToPayRequest = Object.assign( {}, googlePayBaseRequest, { allowedPaymentMethods: googlePayConfig.allowedPaymentMethods } ); const response = await paymentsClient.isReadyToPay( readyToPayRequest ); return response.result; } catch ( err ) { console.error( 'Google Pay eligibility check failed', err ); return false; } } /** * Render the Google Pay button into its method container. */ async function renderGooglePayButton() { const method = paymentMethods.get( 'google_pay' ); if ( ! method || ! googlePayConfig ) { return; } const paymentsClient = getGooglePaymentsClient(); const buttonOptions = Object.assign( getGooglePayButtonStyle(), { onClick: () => onGooglePayButtonClicked( googlePayConfig ), allowedPaymentMethods: googlePayConfig.allowedPaymentMethods } ); const button = paymentsClient.createButton( buttonOptions ); const container = method.containerEl; container.innerHTML = ''; container.append( button ); } /** * Get a Google PaymentsClient configured for the current environment. * * @return {google.payments.api.PaymentsClient} The payments client instance. */ function getGooglePaymentsClient() { return new google.payments.api.PaymentsClient( { environment: frmPayPalVars.mode === 'test' ? 'TEST' : 'PRODUCTION', paymentDataCallbacks: { onPaymentAuthorized } } ); } /** * Map frmPayPalVars.buttonStyle to Google Pay ButtonOptions. * * @return {Object} Google Pay button style options. */ function getGooglePayButtonStyle() { const style = frmPayPalVars.buttonStyle || {}; const options = { buttonSizeMode: 'fill' }; const colorMap = { black: 'black', white: 'white', silver: 'white' }; if ( style.color && colorMap[ style.color ] ) { options.buttonColor = colorMap[ style.color ]; } const typeMap = { pay: 'pay', checkout: 'checkout', buynow: 'buy', donate: 'donate', subscribe: 'subscribe' }; if ( style.label && typeMap[ style.label ] ) { options.buttonType = typeMap[ style.label ]; } if ( style.borderRadius !== undefined ) { options.buttonRadius = style.borderRadius; } return options; } /** * Handle Google Pay button click. * * @param {Object} config The config from paypal.Googlepay().config(). * * @return {Promise} */ async function onGooglePayButtonClicked( config ) { const settings = getPayPalSettings()[ 0 ]; const currency = ( settings.currency || 'USD' ).toUpperCase(); const paymentDataRequest = Object.assign( {}, googlePayBaseRequest ); paymentDataRequest.allowedPaymentMethods = config.allowedPaymentMethods; paymentDataRequest.merchantInfo = config.merchantInfo; paymentDataRequest.callbackIntents = [ 'PAYMENT_AUTHORIZATION' ]; paymentDataRequest.transactionInfo = { currencyCode: currency, totalPriceStatus: 'ESTIMATED', totalPrice: '0.00' }; try { const amount = await new Promise( ( resolve, reject ) => { getPrice( result => { if ( result?.data?.amount ) { resolve( result.data.amount ); } else { reject( new Error( 'No amount' ) ); } } ); } ); paymentDataRequest.transactionInfo.totalPrice = String( amount ); paymentDataRequest.transactionInfo.totalPriceStatus = 'FINAL'; } catch ( e ) { // Fall back to ESTIMATED with 0.00 if we can't get the price. } const paymentsClient = getGooglePaymentsClient(); paymentsClient.loadPaymentData( paymentDataRequest ); } /** * Callback invoked by Google Pay when the buyer authorizes the payment. * * @param {Object} paymentData The Google Pay PaymentData response object. * * @return {Promise} Transaction state result for the Google Pay sheet. */ async function onPaymentAuthorized( paymentData ) { try { const orderId = await createOrderForGooglePay(); const confirmOrderResponse = await paypal.Googlepay().confirmOrder( { orderId, paymentMethodData: paymentData.paymentMethodData } ); if ( confirmOrderResponse.status === 'PAYER_ACTION_REQUIRED' ) { await paypal.Googlepay().initiatePayerAction( { orderId } ); } if ( confirmOrderResponse.status === 'APPROVED' || confirmOrderResponse.status === 'PAYER_ACTION_REQUIRED' ) { await onApprove( { orderID: orderId, paymentSource: 'google_pay' } ); return { transactionState: 'SUCCESS' }; } return { transactionState: 'ERROR', error: { intent: 'PAYMENT_AUTHORIZATION', message: 'Payment could not be authorized' } }; } catch ( err ) { return { transactionState: 'ERROR', error: { intent: 'PAYMENT_AUTHORIZATION', message: err.message || 'Payment failed' } }; } } // ---- Apple Pay ---- /** * Map frmPayPalVars.buttonStyle to Apple Pay button attributes and CSS custom properties. * * The web component uses CSS custom properties for sizing: * --apple-pay-button-width, --apple-pay-button-height, --apple-pay-button-border-radius, * --apple-pay-button-padding, --apple-pay-button-box-sizing. * * @return {Object} Apple Pay button style options. */ function getApplePayButtonStyle() { const style = frmPayPalVars.buttonStyle || {}; const options = { buttonStyle: 'black', buttonType: 'buy' }; const colorMap = { black: 'black', white: 'white', silver: 'white-outline' }; if ( style.color && colorMap[ style.color ] ) { options.buttonStyle = colorMap[ style.color ]; } const typeMap = { pay: 'pay', checkout: 'check-out', buynow: 'buy', donate: 'donate', subscribe: 'subscribe', buy: 'buy' }; if ( style.label && typeMap[ style.label ] ) { options.buttonType = typeMap[ style.label ]; } if ( style.borderRadius !== undefined ) { options.borderRadius = style.borderRadius; } return options; } /** * Render the Apple Pay button into its method container. * * The web component uses CSS custom properties for sizing, * not standard CSS properties or inline styles. */ async function renderApplePayButton() { const method = paymentMethods.get( 'apple_pay' ); if ( ! method ) { return; } const container = method.containerEl; container.innerHTML = ''; // Check if Apple Pay is available on the device. if ( 'undefined' === typeof ApplePaySession ) { return; } const applePayStyle = getApplePayButtonStyle(); const btn = document.createElement( 'apple-pay-button' ); btn.setAttribute( 'buttonstyle', applePayStyle.buttonStyle ); btn.setAttribute( 'type', applePayStyle.buttonType ); btn.setAttribute( 'locale', 'en' ); // Use CSS custom properties (the only way to style the web component). btn.style.setProperty( '--apple-pay-button-width', '100%' ); btn.style.setProperty( '--apple-pay-button-height', '40px' ); btn.style.setProperty( '--apple-pay-button-padding', '6px 0' ); btn.style.setProperty( '--apple-pay-button-box-sizing', 'border-box' ); if ( applePayStyle.borderRadius !== undefined ) { btn.style.setProperty( '--apple-pay-button-border-radius', `${ applePayStyle.borderRadius }px` ); } btn.addEventListener( 'click', onApplePayButtonClick ); container.append( btn ); } /** * Handle click on the Apple Pay button. * * ApplePaySession MUST be created and begun synchronously inside the click * handler. Any async work (like fetching the price) before session.begin() * causes the user-gesture activation to expire, leaving the payment sheet * visible but non-interactive. */ function onApplePayButtonClick() { if ( ! applePayConfig ) { console.error( 'Apple Pay config not available' ); return; } const paymentRequest = { countryCode: applePayConfig.countryCode, merchantCapabilities: applePayConfig.merchantCapabilities, supportedNetworks: applePayConfig.supportedNetworks, currencyCode: applePayConfig.currencyCode || 'USD', requiredBillingContactFields: [ 'postalAddress' ], total: { label: document.title || 'Payment', type: 'final', amount: cachedAmount, }, }; const session = new ApplePaySession( 4, paymentRequest ); session.onvalidatemerchant = event => { applePayInstance.validateMerchant( { validationUrl: event.validationURL, displayName: document.title || 'Payment' } ) .then( validateResult => { session.completeMerchantValidation( validateResult.merchantSession ); } ) .catch( validateError => { console.error( 'Apple Pay merchant validation failed', validateError ); session.abort(); } ); }; session.onpaymentmethodselected = () => { session.completePaymentMethodSelection( { newTotal: paymentRequest.total, } ); }; session.onpaymentauthorized = async event => { let sessionCompleted = false; try { const orderId = await createOrderForApplePay(); const confirmOrderResponse = await applePayInstance.confirmOrder( { orderId, token: event.payment.token, billingContact: event.payment.billingContact, shippingContact: event.payment.shippingContact } ); const approvalStatus = confirmOrderResponse?.approveApplePayPayment?.status; if ( approvalStatus === 'PAYER_ACTION_REQUIRED' ) { await applePayInstance.initiatePayerAction( { orderId } ); } if ( approvalStatus !== 'APPROVED' && approvalStatus !== 'COMPLETED' ) { session.completePayment( ApplePaySession.STATUS_FAILURE ); sessionCompleted = true; return; } session.completePayment( ApplePaySession.STATUS_SUCCESS ); sessionCompleted = true; onApprove( { orderID: orderId, paymentSource: 'apple_pay' } ); } catch ( err ) { console.error( 'Apple Pay payment failed', err ); if ( ! sessionCompleted ) { session.completePayment( ApplePaySession.STATUS_FAILURE ); } reportErrorToServer( err, 'apple_pay' ); } }; session.oncancel = () => { onCancel(); }; session.begin(); } // ---- AJAX / Order Creation ---- /** * Create a PayPal order via AJAX. * * @param {Object} data * @return {Promise} The order ID. */ async function createOrder( data ) { ++running; thisForm.classList.add( 'frm_loading_form' ); const formData = new FormData( thisForm ); formData.append( 'action', 'frm_paypal_create_order' ); formData.append( 'nonce', frmPayPalVars.nonce ); formData.append( 'payment_source', data.paymentSource ); formData.delete( 'frm_action' ); formData.delete( 'form_key' ); formData.delete( 'item_key' ); const response = await fetch( frmPayPalVars.ajax, { method: 'POST', body: formData } ); if ( ! response.ok ) { thisForm.classList.remove( 'frm_loading_form' ); throw new Error( 'Failed to create PayPal order' ); } const orderData = await response.json(); if ( ! orderData.success || ! orderData.data.orderID ) { thisForm.classList.remove( 'frm_loading_form' ); throwServerError( orderData.data, 'Failed to create PayPal order', 'create_order' ); } return orderData.data.orderID; } async function createSubscription( data ) { thisForm.classList.add( 'frm_loading_form' ); const formData = new FormData( thisForm ); formData.append( 'action', 'frm_paypal_create_subscription' ); formData.append( 'nonce', frmPayPalVars.nonce ); formData.delete( 'frm_action' ); formData.delete( 'form_key' ); formData.delete( 'item_key' ); const response = await fetch( frmPayPalVars.ajax, { method: 'POST', body: formData } ); if ( ! response.ok ) { thisForm.classList.remove( 'frm_loading_form' ); const errorData = await response.json(); throwServerError( errorData.data, 'Failed to create PayPal subscription', 'create_subscription' ); } const orderData = await response.json(); if ( ! orderData.success || ! orderData.data.subscriptionID ) { thisForm.classList.remove( 'frm_loading_form' ); throwServerError( orderData.data, 'Failed to create PayPal subscription', 'create_subscription' ); } return orderData.data.subscriptionID; } /** * Create a PayPal order specifically for Google Pay. * * @return {Promise} The PayPal order ID. */ async function createOrderForGooglePay() { const formData = new FormData( thisForm ); formData.append( 'action', 'frm_paypal_create_order' ); formData.append( 'nonce', frmPayPalVars.nonce ); formData.append( 'payment_source', 'google_pay' ); formData.delete( 'frm_action' ); formData.delete( 'form_key' ); formData.delete( 'item_key' ); const response = await fetch( frmPayPalVars.ajax, { method: 'POST', body: formData } ); if ( ! response.ok ) { throw new Error( 'Failed to create PayPal order for Google Pay' ); } const orderData = await response.json(); if ( ! orderData.success || ! orderData.data.orderID ) { throwServerError( orderData.data, 'Failed to create PayPal order for Google Pay', 'create_order' ); } return orderData.data.orderID; } /** * Create a PayPal order specifically for Apple Pay. * * @return {Promise} The PayPal order ID. */ async function createOrderForApplePay() { const formData = new FormData( thisForm ); formData.append( 'action', 'frm_paypal_create_order' ); formData.append( 'nonce', frmPayPalVars.nonce ); formData.append( 'payment_source', 'apple_pay' ); formData.delete( 'frm_action' ); formData.delete( 'form_key' ); formData.delete( 'item_key' ); const response = await fetch( frmPayPalVars.ajax, { method: 'POST', body: formData } ); if ( ! response.ok ) { throw new Error( 'Failed to create PayPal order for Apple Pay' ); } const orderData = await response.json(); if ( ! orderData.success || ! orderData.data.orderID ) { throwServerError( orderData.data, 'Failed to create PayPal order for Apple Pay', 'create_order' ); } return orderData.data.orderID; } // ---- Payment Callbacks ---- /** * Handle approved payment. * * @param {Object} data The approval data containing orderID. */ async function onApprove( data ) { if ( 'NO' === data.liabilityShift || 'UNKNOWN' === data.liabilityShift ) { onError( new Error( 'This payment was flagged as possible fraud and has been rejected.' ) ); return; } if ( data.orderID ) { const orderInput = document.createElement( 'input' ); orderInput.type = 'hidden'; orderInput.name = 'paypal_order_id'; orderInput.value = data.orderID; thisForm.append( orderInput ); } if ( data.subscriptionID ) { const subscriptionInput = document.createElement( 'input' ); subscriptionInput.type = 'hidden'; subscriptionInput.name = 'paypal_subscription_id'; subscriptionInput.value = data.subscriptionID; thisForm.append( subscriptionInput ); } const paymentSourceInput = document.createElement( 'input' ); paymentSourceInput.type = 'hidden'; paymentSourceInput.name = 'paypal_payment_source'; // When onApprove is called for card fields, there is no paymentSource specified. paymentSourceInput.value = data.paymentSource || 'card'; thisForm.append( paymentSourceInput ); // If using the PayPal buttons to submit, there will not be a submitEvent. if ( ! submitEvent ) { submitEvent = new Event( 'submit', { cancelable: true, bubbles: true } ); submitEvent.target = thisForm; } frmFrontForm.submitFormManual( submitEvent, thisForm ); } /** * Handle payment errors. * * @param {Error} err The error object. */ function onError( err ) { console.error( 'PayPal onError:', err ); running--; if ( running === 0 && thisForm ) { if ( selectedMethod === 'card' && cardFieldsValid ) { enableSubmit(); } else { frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'disable', 0 ); } } reportErrorToServer( err ); } /** * Report a PayPal error to the server for logging and permission-aware display. * * Posts the error message and debug ID to the server endpoint. * The server logs the debug ID and returns a display message that * includes the debug ID only for authorized users. * * @param {*} err The error object, string, or PayPal error payload. * @param {string} context A label for where the error occurred (e.g. 'card_submit'). */ let lastDebugId = ''; let lastContext = ''; function reportErrorToServer( err, context ) { let errorMessage = extractErrorMessage( err ); // Extract debug ID from error message if it's in {{debug_id:...}} format let debugId = lastDebugId || ( err?.debugId ? err.debugId : '' ); const debugIdMatch = errorMessage.match( /\{\{debug_id:([^}]+)\}\}/ ); if ( debugIdMatch && debugIdMatch[ 1 ] ) { debugId = debugIdMatch[ 1 ]; // Remove the debug ID from the error message for display errorMessage = errorMessage.replace( /\{\{debug_id:[^}]+\}\}/, '' ).trim(); } const errorContext = context || lastContext || ''; lastDebugId = ''; lastContext = ''; const formData = new FormData(); formData.append( 'action', 'frm_paypal_report_error' ); formData.append( 'nonce', frmPayPalVars.nonce ); formData.append( 'error_message', errorMessage ); formData.append( 'debug_id', debugId ); formData.append( 'context', errorContext ); fetch( frmPayPalVars.ajax, { method: 'POST', body: formData } ) .then( response => response.json() ) .then( result => { if ( result.success && result.data?.message ) { displayPaymentFailure( result.data.message ); } else { displayPaymentFailure( errorMessage ); } } ) .catch( () => { displayPaymentFailure( errorMessage ); } ); } /** * Throw an Error from a server error response. * * @param {*} data The response data from wp_send_json_error. * @param {string} fallback Fallback message if data is unusable. * @param {string|undefined} context Current context. Could be 'create_order', 'create_subscription'. */ function throwServerError( data, fallback, context ) { let message = fallback; if ( data && 'object' === typeof data && data.message ) { message = data.message; lastDebugId = data.debug_id || ''; } else if ( 'string' === typeof data && data ) { const debugMatch = data.match( /\{\{debug_id:([^}]+)\}\}/ ); if ( debugMatch ) { lastDebugId = debugMatch[ 1 ]; message = data.replace( /\{\{debug_id:[^}]+\}\}/, '' ).trim(); } else { message = data; } } lastContext = context || ''; throw new Error( message ); } function onCancel() { thisForm.classList.add( 'frm_loading_form' ); frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'disable', 0 ); } // ---- Submit Button Helpers ---- /** * Enable the submit button for the form. */ function enableSubmit() { if ( running > 0 ) { return; } thisForm.classList.add( 'frm_loading_form' ); frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'enable', 0 ); const event = new CustomEvent( 'frmPayPalLiteEnableSubmit', { detail: { form: thisForm } } ); document.dispatchEvent( event ); } /** * Disable submit button for a target form. * * @param {Element} form * @return {void} */ function disableSubmit( form ) { jQuery( form ).find( 'input[type="submit"],input[type="button"],button[type="submit"]' ).not( '.frm_prev_page' ).attr( 'disabled', 'disabled' ); const event = new CustomEvent( 'frmPayPalLiteDisableSubmit', { detail: { form } } ); document.dispatchEvent( event ); } // ---- Error Display ---- /** * Extract a user-friendly message from a PayPal error. * * PayPal CardFields rejections surface a structured payload that includes a * `details` array with per-field `description` strings (e.g. "Invalid card * number"). We prefer those over the generic top-level `message` so the * buyer sees the actionable reason. * * @param {*} err The thrown error, string, or PayPal error payload. * @return {string} A human-readable message. */ function extractErrorMessage( err ) { const fallback = 'Payment failed. Please try again.'; if ( ! err ) { return fallback; } if ( 'string' === typeof err ) { const parsed = parsePayPalErrorString( err ); if ( parsed ) { return parsed; } return mapPayPalErrorCode( err ) || err; } // PayPal SDK sometimes nests the payload under `err.data` or `err.response`. const payloads = [ err, err.data, err.response ].filter( Boolean ); for ( const payload of payloads ) { const fromDetails = getDescriptionFromDetails( payload.details ); if ( fromDetails ) { return fromDetails; } } if ( err.message ) { const parsed = parsePayPalErrorString( err.message ); if ( parsed ) { return parsed; } return mapPayPalErrorCode( err.message ) || err.message; } return fallback; } /** * Map PayPal error codes to user-friendly messages. * * @param {string} code The PayPal error code (e.g. INVALID_CVV). * @return {string} The user-friendly message, or empty string if not mapped. */ function mapPayPalErrorCode( code ) { const codeMap = { INVALID_CVV: 'Please enter a valid CVV code.', INVALID_CARD_NUMBER: 'Please enter a valid card number.', INVALID_EXPIRY: 'Please enter a valid expiry date.', }; const upperCode = code.toUpperCase(); return codeMap[ upperCode ] || ''; } /** * Extract the first `description` from a PayPal `details` array. * * @param {Array} details The PayPal error details array. * @return {string} The description, or an empty string if none. */ function getDescriptionFromDetails( details ) { if ( ! Array.isArray( details ) || ! details.length ) { return ''; } for ( const detail of details ) { if ( detail?.description ) { return detail.description; } } return ''; } /** * Parse a PayPal error string that may contain an embedded JSON payload. * * The CardFields SDK can throw errors whose message looks like: * `Error: ... {"name":"UNPROCESSABLE_ENTITY","details":[...], ...}`. * * @param {string} str The raw error string. * @return {string} The extracted description, or an empty string. */ function parsePayPalErrorString( str ) { const start = str.indexOf( '{' ); const end = str.lastIndexOf( '}' ); if ( start === -1 || end === -1 || end <= start ) { return ''; } try { const payload = JSON.parse( str.slice( start, end + 1 ) ); return getDescriptionFromDetails( payload.details ); } catch ( e ) { return ''; } } /** * Display an error message in the payment form. * * @param {string} errorMessage The message to display. May contain a newline-separated * debug ID line when the server includes it for authorized users. * @return {void} */ function displayPaymentFailure( errorMessage ) { if ( ! thisForm ) { return; } const statusContainer = thisForm.querySelector( '.frm-card-errors' ); if ( statusContainer ) { statusContainer.textContent = ''; const lines = errorMessage.split( '\n' ); lines.forEach( function( line, index ) { if ( index > 0 ) { statusContainer.append( document.createElement( 'br' ) ); } statusContainer.append( document.createTextNode( line ) ); } ); statusContainer.style.display = 'block'; } } /** * Clear error messages. */ function clearErrors() { if ( ! thisForm ) { return; } const statusContainer = thisForm.querySelector( '.frm-card-errors' ); if ( statusContainer ) { statusContainer.textContent = ''; statusContainer.style.display = 'none'; } } // ---- Form Submission ---- /** * Validate the form before submission. * * @param {Element} form * @return {boolean} True if valid. */ function validateFormSubmit( form ) { if ( typeof frmFrontForm.validateFormSubmit !== 'function' ) { return true; } const errors = frmFrontForm.validateFormSubmit( form ); const keys = Object.keys( errors ); if ( 1 === keys.length && errors[ keys[ 0 ] ] === '' ) { keys.pop(); } return 0 === keys.length; } /** * Check if the current form action type should trigger payment processing. * * @return {boolean} True if current action type should be processed. */ function currentActionTypeShouldBeProcessed() { const action = jQuery( thisForm ).find( 'input[name="frm_action"]' ).val(); if ( 'object' !== typeof window.frmProForm || 'function' !== typeof window.frmProForm.currentActionTypeShouldBeProcessed ) { return 'create' === action; } return window.frmProForm.currentActionTypeShouldBeProcessed( action, { thisForm } ); } /** * Handle form submission. Routes to card submission when card is selected. * For button-based methods (PayPal, Venmo, etc.) the SDK handles submission via onApprove. * * @param {Event} event */ async function handleFormSubmission( event ) { if ( ! currentActionTypeShouldBeProcessed() ) { return; } // Only intercept submission when card is the selected method. if ( selectedMethod !== 'card' ) { return; } event.preventDefault(); event.stopPropagation(); submitEvent = event; clearErrors(); thisForm.classList.add( 'frm_js_validate' ); if ( ! validateFormSubmit( thisForm ) ) { return; } disableSubmit( thisForm ); const meta = addName( jQuery( thisForm ) ); const submitArgs = {}; if ( meta.name ) { submitArgs.cardholderName = meta.name; } const billingAddress = getBillingAddress(); if ( billingAddress ) { submitArgs.billingAddress = billingAddress; } try { await cardFieldsInstance.submit( submitArgs ); } catch ( err ) { console.error( 'Card fields submit error:', err ); running--; if ( running === 0 && thisForm ) { enableSubmit(); } reportErrorToServer( err, 'card_submit' ); } } /** * Build the billing address for the card fields from the address field mapped in the payment action. * * @since 6.34 * * @return {Object|null} Billing address for the card fields submit args, or null when no address is available. */ function getBillingAddress() { let addressID = ''; getPayPalSettings().forEach( function( setting ) { if ( setting.address ) { addressID = setting.address; } } ); if ( '' === addressID ) { return null; } let prefix = ''; let addressContainer = document.querySelector( `#frm_field_${ addressID }_container, .frm_field_${ addressID }_container` ); if ( ! addressContainer ) { const line1Input = document.querySelector( `input[name="item_meta[${ addressID }][line1]"]` ); if ( line1Input ) { prefix = `${ addressID }][`; addressContainer = line1Input.parentNode; } } if ( ! addressContainer ) { return null; } const getSubFieldValue = function( name ) { const input = addressContainer.querySelector( `input[name$="[${ prefix }${ name }]"], select[name$="[${ prefix }${ name }]"]` ); return input?.value ? input.value : ''; }; const subFieldMapping = { line1: 'addressLine1', line2: 'addressLine2', city: 'adminArea2', state: 'adminArea1', zip: 'postalCode' }; const billingAddress = {}; Object.keys( subFieldMapping ).forEach( function( name ) { const value = getSubFieldValue( name ); if ( value ) { billingAddress[ subFieldMapping[ name ] ] = value; } } ); if ( ! billingAddress.addressLine1 ) { return null; } const countryCode = getCountryCode( addressContainer, prefix ); if ( countryCode ) { billingAddress.countryCode = countryCode; } return billingAddress; } /** * Get the two letter country code for the filled address. * The country dropdown holds the code in a data-code attribute. * US type address fields have a state dropdown and no country field, so US is assumed for them. * * @since 6.34 * * @param {Element} addressContainer * @param {string} prefix * @return {string} Country code, or an empty string when the country is unknown. */ function getCountryCode( addressContainer, prefix ) { const countryDropdown = addressContainer.querySelector( `select[name$="[${ prefix }country]"]` ); if ( countryDropdown ) { const countryOption = countryDropdown.querySelector( `option[value="${ countryDropdown.value }"]` ); return countryOption?.getAttribute( 'data-code' ) || ''; } const stateDropdown = addressContainer.querySelector( `select[name$="[${ prefix }state]"]` ); return stateDropdown ? 'US' : ''; } // ---- Price / Pay Later ---- /** * Get PayPal settings from frmPayPalVars.settings. * * @return {Array} Array of PayPal settings. */ function getPayPalSettings() { const paypalSettings = []; frmPayPalVars.settings.forEach( function( setting ) { if ( setting.gateways.includes( 'paypal' ) ) { paypalSettings.push( setting ); } } ); return paypalSettings; } /** * Get the field IDs that affect the price. * * @return {Array} Array of field IDs. */ function getPriceFields() { const priceFields = []; getPayPalSettings().forEach( function( setting ) { if ( -1 !== setting.fields ) { setting.fields.forEach( function( field ) { if ( isNaN( field ) ) { priceFields.push( `field_${ field }` ); } else { priceFields.push( field ); } } ); } } ); return priceFields; } /** * Handle price field changes. * * @param {Event} _ The event object. * @param {HTMLElement} field The changed field element. * @param {string} fieldId The changed field ID. */ function priceChanged( _, field, fieldId ) { const price = getPriceFields(); let run = price.includes( fieldId ) || price.includes( field.id ); if ( ! run ) { for ( let i = 0; i < price.length; i++ ) { if ( field.id.indexOf( price[ i ] ) === 0 ) { run = true; break; } } } if ( ! run ) { return; } const form = field.closest ? field.closest( 'form' ) : jQuery( field ).closest( 'form' )[ 0 ]; if ( ! form ) { return; } getPrice( function( result ) { updatePayLaterMessage( result.data.amount ); } ); } function getPrice( callback ) { const formData = new FormData( thisForm ); formData.append( 'action', 'frm_paypal_get_amount' ); formData.append( 'nonce', frmPayPalVars.nonce ); formData.delete( 'frm_action' ); formData.delete( 'form_key' ); formData.delete( 'item_key' ); fetch( frmPayPalVars.ajax, { method: 'POST', body: formData } ) .then( response => response.json() ) .then( function( result ) { if ( result.success && result.data?.amount ) { cachedAmount = String( result.data.amount ); callback( result ); } } ) .catch( function( err ) { console.error( 'Failed to get PayPal amount', err ); } ); } /** * Refresh the cached amount for Apple Pay. */ function refreshCachedAmount() { getPrice( function() {} ); } /** * Refresh the cached amount when a price-related field changes. * * @param {Event} _ jQuery event. * @param {HTMLElement} field The changed field element. * @param {string} fieldId The changed field ID. */ function refreshCachedAmountOnFieldChange( _, field, fieldId ) { const price = getPriceFields(); let run = price.includes( fieldId ) || price.includes( field.id ); if ( ! run ) { for ( let i = 0; i < price.length; i++ ) { if ( field.id.indexOf( price[ i ] ) === 0 ) { run = true; break; } } } if ( run ) { refreshCachedAmount(); } } /** * Re-render the Pay Later message with the current amount. * * @param {number|string} amount * * @return {void} */ function updatePayLaterMessage( amount ) { const banner = document.getElementById( 'frm-paylater-message' ); if ( banner ) { banner.setAttribute( 'data-pp-amount', amount ); } } function renderMessages() { if ( 'function' !== typeof paypal.Messages ) { return; } const container = document.getElementById( 'frm-paylater-message' ); if ( ! container ) { return; } getPrice( function( result ) { container.setAttribute( 'data-pp-amount', result.data.amount ); } ); paypal.Messages( { style: { layout: 'text', logo: { type: 'primary' }, } } ).render( '#frm-paylater-message' ); } /** * Check for price fields on load and trigger an initial price update. */ function checkPriceFieldsOnLoad() { getPriceFields().forEach( function( fieldId ) { const fieldContainer = document.getElementById( `frm_field_${ fieldId }_container` ); if ( ! fieldContainer ) { return; } const input = fieldContainer.querySelector( 'input[name^=item_meta]' ); if ( input && '' !== input.value ) { priceChanged( null, input, fieldId ); } } ); } // ---- Name Fields ---- function addName( $form ) { let i; let firstField; let lastField; let firstFieldContainer; let lastFieldContainer; let firstNameID = ''; let lastNameID = ''; let subFieldEl; const cardObject = {}; const { settings } = frmPayPalVars; /** * Gets first, middle or last name from the given field. * * @param {number|HTMLElement} field Field ID or Field element. * @param {string} subFieldName Subfield name. * @return {string} Name field value. */ const getNameFieldValue = function( field, subFieldName ) { if ( 'object' !== typeof field ) { field = document.getElementById( `frm_field_${ field }_container` ); } if ( ! field || 'object' !== typeof field || 'function' !== typeof field.querySelector ) { return ''; } subFieldEl = field.querySelector( `.frm_combo_inputs_container .frm_form_subfield-${ subFieldName } input` ); if ( ! subFieldEl ) { return ''; } return subFieldEl.value; }; for ( i = 0; i < settings.length; i++ ) { firstNameID = settings[ i ].first_name; lastNameID = settings[ i ].last_name; } /** * Returns a name field container or element. * * @param {number} fieldID * @param {string} type Either 'container' or 'field' * @param {object|null} $form * @return {HTMLElement|null} Name field container or element. */ function getNameFieldItem( fieldID, type, $form = null ) { const queryForNameFieldIsFound = 'object' === typeof window.frmProForm && 'function' === typeof window.frmProForm.queryForNameField; if ( type === 'container' ) { return queryForNameFieldIsFound ? window.frmProForm.queryForNameField( fieldID, 'container' ) : document.querySelector( `#frm_field_${ fieldID }_container, .frm_field_${ fieldID }_container` ); } return queryForNameFieldIsFound ? window.frmProForm.queryForNameField( fieldID, 'field', $form[ 0 ] ) : $form[ 0 ].querySelector( `#frm_field_${ fieldID }_container input, input[name="item_meta[${ fieldID }]"], .frm_field_${ fieldID }_container input` ); } if ( firstNameID !== '' ) { firstFieldContainer = getNameFieldItem( firstNameID, 'container' ); if ( firstFieldContainer?.querySelector( '.frm_combo_inputs_container' ) ) { cardObject.name = getNameFieldValue( firstFieldContainer, 'first' ); } else { firstField = getNameFieldItem( firstNameID, 'field', $form ); if ( firstField?.value ) { cardObject.name = firstField.value; } } } if ( lastNameID !== '' ) { lastFieldContainer = getNameFieldItem( lastNameID, 'container' ); if ( lastFieldContainer?.querySelector( '.frm_combo_inputs_container' ) ) { cardObject.name = `${ cardObject.name } ${ getNameFieldValue( lastFieldContainer, 'last' ) }`; } else { lastField = getNameFieldItem( lastNameID, 'field', $form ); if ( lastField?.value ) { cardObject.name = `${ cardObject.name } ${ lastField.value }`; } } } return cardObject; } // ---- Bootstrap ---- document.addEventListener( 'DOMContentLoaded', async function() { if ( window.paypal ) { paypalInit(); return; } const interval = setInterval( function() { if ( window.paypal ) { paypalInit(); clearInterval( interval ); } }, 50 ); } ); jQuery( document ).on( 'frmPageChanged', function() { paypalInit(); } ); }() );