PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / 6.33.1
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More v6.33.1
6.35 6.34 6.33.1 6.33 6.32.1 6.32 6.31 6.25 6.25.1 6.26 6.26.1 6.27 6.28 6.29 6.3 6.3.1 6.3.2 6.30 6.4 6.4.1 6.4.2 6.5 6.5.1 6.5.2 6.5.3 All 141 releases
formidable / paypal / js / frontend.js

frontend.js in Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More 6.33.1, at paypal/js/frontend.js

2,107 lines 58.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * PayPal Payment Integration - Radio-Based Payment Method Selector.
3 *
4 * Architecture:
5 * - A radio group lets the user pick their payment method (Card, PayPal, Venmo, etc.).
6 * - Only the selected method's UI is visible at a time.
7 * - Card + PayPal are pre-rendered on init (hybrid approach).
8 * - Other methods (Venmo, Google Pay, etc.) are lazy-rendered on first selection, then cached.
9 * - When Card is selected: card fields + native submit button are shown.
10 * - When any button method is selected: the submit button is hidden and only that button is shown.
11 */
12 ( function() {
13 if ( ! window.frmPayPalVars ) {
14 return;
15 }
16
17 // ---- State ----
18
19 let thisForm = null;
20 let running = 0;
21 let cardFieldsInstance = null;
22 let cardFieldsValid = false;
23 let submitEvent = null;
24 let isRecurring = false;
25
26 /**
27 * Registry of available payment methods.
28 * Populated during init based on SDK eligibility checks.
29 *
30 * @type {Map<string, Object>}
31 */
32 const paymentMethods = new Map();
33
34 /** Currently selected payment method key. */
35 let selectedMethod = null;
36
37 /** Cached Google Pay config from paypal.Googlepay().config(). */
38 let googlePayConfig = null;
39
40 /** Cached Apple Pay config from paypal.Applepay().config(). */
41 let applePayConfig = null;
42
43 /** Cached Apple Pay SDK instance (reused for validateMerchant and confirmOrder). */
44 let applePayInstance = null;
45
46 /** Cached payment amount for Apple Pay (must be available synchronously in click handler). */
47 let cachedAmount = '0.00';
48
49 // ---- Constants ----
50
51 /**
52 * Human-readable labels for funding sources.
53 */
54 const METHOD_LABELS = {
55 card: 'Credit Card',
56 paypal: 'PayPal',
57 venmo: 'Venmo',
58 paylater: 'Pay Later',
59 google_pay: 'Google Pay',
60 apple_pay: 'Apple Pay',
61 bancontact: 'Bancontact',
62 blik: 'BLIK',
63 eps: 'EPS',
64 p24: 'Przelewy24',
65 trustly: 'Trustly',
66 satispay: 'Satispay',
67 sepa: 'SEPA',
68 mybank: 'MyBank',
69 ideal: 'iDEAL',
70 };
71
72 /**
73 * Maps internal method keys to PayPal FUNDING constants for the Marks API.
74 * Card and Google Pay use local images instead of PayPal Marks.
75 */
76 const METHOD_FUNDING_SOURCE = {
77 paypal: 'paypal',
78 venmo: 'venmo',
79 paylater: 'paylater',
80 bancontact: 'bancontact',
81 blik: 'blik',
82 eps: 'eps',
83 p24: 'p24',
84 trustly: 'trustly',
85 satispay: 'satispay',
86 sepa: 'sepa',
87 mybank: 'mybank',
88 ideal: 'ideal',
89 };
90
91 /**
92 * Methods that should be pre-rendered on init (hybrid approach).
93 * Everything else is lazy-rendered on first selection.
94 */
95 const PRE_RENDER_METHODS = new Set( [ 'card', 'paypal' ] );
96
97 /**
98 * Base request object shared by isReadyToPay and PaymentDataRequest.
99 */
100 const googlePayBaseRequest = {
101 apiVersion: 2,
102 apiVersionMinor: 0
103 };
104
105 // ---- Initialization ----
106
107 /**
108 * Main entry point.
109 */
110 async function paypalInit() {
111 const cardElement = document.querySelector( '.frm-card-element' );
112 if ( ! cardElement ) {
113 return;
114 }
115
116 thisForm = cardElement.closest( 'form' );
117 if ( ! thisForm ) {
118 return;
119 }
120
121 const settings = getPayPalSettings()[ 0 ];
122 if ( ! settings ) {
123 return;
124 }
125
126 isRecurring = 'single' !== settings.one;
127 const { paypalLayout: layout } = settings;
128 const cardFieldsAreSupported = layout !== 'checkout_only' && 'function' === typeof window.paypal.CardFields;
129 const buttonsAreEnabled = layout !== 'card_only' && 'function' === typeof window.paypal.Buttons;
130
131 // Clear the card element. We rebuild it entirely.
132 cardElement.innerHTML = '';
133
134 // Reset state so each run rediscovers cleanly (the DOM above was cleared).
135 paymentMethods.clear();
136 selectedMethod = null;
137
138 // 1. Discover synchronous methods (Card, PayPal, alternative funding).
139 await discoverPaymentMethods( {
140 cardFieldsAreSupported,
141 buttonsAreEnabled,
142 isRecurring
143 } );
144
145 // 1b. Discover Google Pay / Apple Pay. Their SDKs (pay.js / PayPal applepay
146 // component) load asynchronously and are frequently not ready during the first
147 // run, which previously left these buttons missing on first load. We wait
148 // (bounded) for them here so every eligible method appears together in one
149 // render instead of popping in afterward.
150 await discoverDeferredPaymentMethods( { buttonsAreEnabled, isRecurring } );
151
152 if ( paymentMethods.size === 0 ) {
153 displayPaymentFailure( 'No payment methods available.' );
154 return;
155 }
156
157 // 2. Build the radio selector UI, then render marks after it's in the DOM.
158 // Hide the radio group if there's only one payment method available.
159 const radioGroup = buildRadioGroup();
160 if ( paymentMethods.size === 1 ) {
161 radioGroup.style.display = 'none';
162 }
163 cardElement.append( radioGroup );
164 renderMarks();
165
166 // 3. Build a container area for each method's UI (buttons / card fields).
167 const methodArea = document.createElement( 'div' );
168 methodArea.classList.add( 'frm-payment-method-area' );
169 cardElement.append( methodArea );
170
171 for ( const [ key, method ] of paymentMethods ) {
172 const container = document.createElement( 'div' );
173 container.id = `frm-payment-method-${ key }`;
174 container.classList.add( 'frm-payment-method-container' );
175 methodArea.append( container );
176 method.containerEl = container;
177 }
178
179 // 4. Pre-render Card + PayPal (hybrid approach).
180 for ( const key of PRE_RENDER_METHODS ) {
181 const method = paymentMethods.get( key );
182 if ( method?.eligible ) {
183 try {
184 await method.render();
185 method.rendered = true;
186 } catch ( err ) {
187 console.error( `Failed to pre-render payment method: ${ key }`, err );
188 }
189 }
190 }
191
192 // 5. Auto-select the first eligible method.
193 const firstKey = paymentMethods.keys().next().value;
194 await selectPaymentMethod( firstKey );
195
196 // 6. Attach form submit handler (for card method).
197 thisForm.addEventListener( 'submit', handleFormSubmission );
198
199 // 7. Pay Later messages.
200 if ( paymentMethods.has( 'paylater' ) ) {
201 renderMessages();
202 jQuery( document ).on( 'frmFieldChanged', priceChanged );
203 checkPriceFieldsOnLoad();
204 }
205
206 // 8. Pre-fetch the amount for Apple Pay so it is available synchronously in the click handler.
207 if ( paymentMethods.has( 'apple_pay' ) ) {
208 refreshCachedAmount();
209 if ( ! paymentMethods.has( 'paylater' ) ) {
210 jQuery( document ).on( 'frmFieldChanged', refreshCachedAmountOnFieldChange );
211 }
212 }
213 }
214
215 // ---- Discovery ----
216
217 /**
218 * Discover which payment methods are eligible and register them.
219 *
220 * @param {Object} opts Config flags.
221 */
222 async function discoverPaymentMethods( opts ) {
223 const { cardFieldsAreSupported, buttonsAreEnabled, isRecurring } = opts;
224
225 // --- Card Fields ---
226 // Card fields are not supported for recurring payments
227 if ( cardFieldsAreSupported && ! isRecurring ) {
228 const cardFields = createCardFieldsSDKInstance();
229 if ( cardFields?.isEligible() ) {
230 cardFieldsInstance = cardFields;
231 registerMethod( 'card', {
232 eligible: true,
233 render: renderCardFields
234 } );
235 }
236 }
237
238 // --- PayPal button ---
239 if ( buttonsAreEnabled ) {
240 const paypalBtn = createPayPalButton( paypal.FUNDING.PAYPAL, isRecurring );
241 if ( paypalBtn.isEligible() ) {
242 registerMethod( 'paypal', {
243 eligible: true,
244 buttonInstance: paypalBtn,
245 render() {
246 this.buttonInstance.render( `#${ this.containerEl.id }` );
247 }
248 } );
249 }
250 }
251
252 // --- Alternative funding sources ---
253 if ( buttonsAreEnabled ) {
254 const fundingSources = [
255 { key: 'venmo', funding: paypal.FUNDING.VENMO },
256 { key: 'paylater', funding: paypal.FUNDING.PAYLATER },
257 { key: 'bancontact', funding: paypal.FUNDING.BANCONTACT },
258 { key: 'blik', funding: paypal.FUNDING.BLIK },
259 { key: 'eps', funding: paypal.FUNDING.EPS },
260 { key: 'p24', funding: paypal.FUNDING.P24 },
261 { key: 'trustly', funding: paypal.FUNDING.TRUSTLY },
262 { key: 'satispay', funding: paypal.FUNDING.SATISPAY },
263 { key: 'sepa', funding: paypal.FUNDING.SEPA },
264 { key: 'mybank', funding: paypal.FUNDING.MYBANK },
265 { key: 'ideal', funding: paypal.FUNDING.IDEAL },
266 ];
267
268 for ( const { key, funding } of fundingSources ) {
269 const btn = createPayPalButton( funding, isRecurring );
270 if ( btn.isEligible() ) {
271 registerMethod( key, {
272 eligible: true,
273 buttonInstance: btn,
274 render() {
275 this.buttonInstance.render( `#${ this.containerEl.id }` );
276 }
277 } );
278 }
279 }
280 }
281
282 // Google Pay and Apple Pay are discovered separately in
283 // discoverDeferredPaymentMethods() because their SDKs (pay.js / PayPal applepay
284 // component) may not be ready yet when this runs.
285 }
286
287 /**
288 * Discover and register Google Pay and Apple Pay.
289 *
290 * Their SDKs load asynchronously and are frequently not ready during the first
291 * paypalInit() run, which previously caused these buttons to be missing on first
292 * load and only appear after a page change. We wait (bounded) for each SDK and
293 * register the eligible methods so they are included when the selector is built.
294 *
295 * @param {Object} opts Config flags.
296 *
297 * @return {Promise<void>}
298 */
299 async function discoverDeferredPaymentMethods( opts ) {
300 const { buttonsAreEnabled, isRecurring } = opts;
301
302 if ( ! buttonsAreEnabled || isRecurring ) {
303 return;
304 }
305
306 // Skip (and avoid the SDK wait) when Google Pay / Apple Pay were not enqueued
307 // server-side, e.g. via the frm_include_google_pay_apple_pay filter or non-SSL.
308 if ( ! frmPayPalVars.includeGooglePayApplePay ) {
309 return;
310 }
311
312 // Resolve both eligibility checks in parallel so the combined wait is bounded by
313 // the slower of the two, then register them in a fixed order (Google Pay, then
314 // Apple Pay). Registration happens before the selector is built, so they render
315 // together with the other methods.
316 const [ googlePayEligible, applePayEligible ] = await Promise.all( [
317 resolveGooglePayEligibility(),
318 resolveApplePayEligibility()
319 ] );
320
321 if ( googlePayEligible ) {
322 registerMethod( 'google_pay', {
323 eligible: true,
324 render: renderGooglePayButton
325 } );
326 }
327
328 if ( applePayEligible ) {
329 registerMethod( 'apple_pay', {
330 eligible: true,
331 render: renderApplePayButton
332 } );
333 }
334 }
335
336 /**
337 * Wait for the Google Pay SDK and resolve whether Google Pay is eligible.
338 *
339 * @return {Promise<boolean>} Whether Google Pay is eligible.
340 */
341 async function resolveGooglePayEligibility() {
342 const sdkReady = await waitFor(
343 () => 'function' === typeof paypal.Googlepay && 'undefined' !== typeof google && undefined !== google.payments
344 );
345 if ( ! sdkReady ) {
346 return false;
347 }
348
349 return checkGooglePayEligibility();
350 }
351
352 /**
353 * Wait for the Apple Pay SDK and resolve whether Apple Pay is eligible.
354 *
355 * @return {Promise<boolean>} Whether Apple Pay is eligible.
356 */
357 async function resolveApplePayEligibility() {
358 // Wait for Apple Pay session to be available (requires Apple Pay SDK to load).
359 const sessionReady = await waitFor( () => undefined !== window.ApplePaySession );
360 if ( ! sessionReady ) {
361 return false;
362 }
363
364 // Wait for canMakePayments to return true.
365 const canMakePaymentsReady = await waitFor( () => ApplePaySession.canMakePayments() );
366 if ( ! canMakePaymentsReady ) {
367 return false;
368 }
369
370 // First ensure the PayPal SDK itself is loaded.
371 const paypalReady = await waitFor( () => 'object' === typeof window.paypal );
372 if ( ! paypalReady ) {
373 return false;
374 }
375
376 // Wait for the Apple Pay function to be available.
377 const sdkReady = await waitFor( () => 'function' === typeof paypal.Applepay );
378 if ( ! sdkReady ) {
379 return false;
380 }
381
382 // Wait for the config call to actually succeed, not just the function to exist.
383 // This handles the race condition where the function exists but SDK isn't fully initialized.
384 const configReady = await waitFor( async () => {
385 try {
386 const instance = paypal.Applepay();
387 const config = await instance.config();
388 return config?.isEligible;
389 } catch ( e ) {
390 return false;
391 }
392 } );
393
394 if ( ! configReady ) {
395 return false;
396 }
397
398 // Config succeeded, cache it and return true.
399 applePayInstance = paypal.Applepay();
400 applePayConfig = await applePayInstance.config();
401 return true;
402 }
403
404 /**
405 * Poll for a condition until it is true or a timeout elapses.
406 *
407 * @param {Function} predicate Returns true (or a Promise resolving to true) when the awaited dependency is ready.
408 * @param {number} [timeout] Maximum time to wait, in milliseconds.
409 * @param {number} [interval] Poll interval, in milliseconds.
410 *
411 * @return {Promise<boolean>} Whether the predicate became true before the timeout.
412 */
413 async function waitFor( predicate, timeout = 3000, interval = 50 ) {
414 const checkPredicate = async () => {
415 const result = predicate();
416 if ( result instanceof Promise ) {
417 return await result;
418 }
419 return result;
420 };
421
422 if ( await checkPredicate() ) {
423 return true;
424 }
425
426 const start = Date.now();
427 return new Promise( resolve => {
428 const timer = setInterval( async () => {
429 if ( await checkPredicate() ) {
430 clearInterval( timer );
431 resolve( true );
432 } else if ( Date.now() - start >= timeout ) {
433 clearInterval( timer );
434 resolve( false );
435 }
436 }, interval );
437 } );
438 }
439
440 /**
441 * Register a payment method in the registry.
442 *
443 * @param {string} key Unique identifier.
444 * @param {Object} config Method configuration.
445 */
446 function registerMethod( key, config ) {
447 paymentMethods.set( key, {
448 key,
449 label: METHOD_LABELS[ key ] || key,
450 eligible: config.eligible || false,
451 rendered: false,
452 containerEl: null,
453 buttonInstance: config.buttonInstance || null,
454 render: config.render || ( () => {} ),
455 } );
456 }
457
458 // ---- Radio Group UI ----
459
460 /**
461 * Build the radio button group for payment method selection.
462 * Each option is a card-like row with a radio, label, description, and PayPal Mark logo.
463 *
464 * @return {HTMLElement} The radio group container.
465 */
466 function buildRadioGroup() {
467 const group = document.createElement( 'div' );
468 group.classList.add( 'frm-payment-method-selector' );
469 group.setAttribute( 'role', 'radiogroup' );
470 group.setAttribute( 'aria-label', 'Select payment method' );
471
472 for ( const [ key, method ] of paymentMethods ) {
473 group.append( buildMethodOption( key, method ) );
474 }
475
476 return group;
477 }
478
479 /**
480 * Build a single payment method radio option row.
481 *
482 * @param {string} key The payment method key.
483 * @param {Object} method The registered method object.
484 *
485 * @return {HTMLElement} The option element (a label, or a wrapper for Pay Later).
486 */
487 function buildMethodOption( key, method ) {
488 const label = document.createElement( 'label' );
489 label.classList.add( 'frm-payment-method-option' );
490 label.setAttribute( 'for', `frm-payment-method-radio-${ key }` );
491
492 const radio = document.createElement( 'input' );
493 radio.type = 'radio';
494 radio.name = 'frm_payment_method';
495 radio.id = `frm-payment-method-radio-${ key }`;
496 radio.value = key;
497
498 radio.addEventListener( 'change', () => selectPaymentMethod( key ) );
499
500 // Text column: label + description.
501 const textWrap = document.createElement( 'div' );
502 textWrap.classList.add( 'frm-payment-method-text' );
503
504 const labelText = document.createElement( 'span' );
505 labelText.classList.add( 'frm-payment-method-label-text' );
506 labelText.textContent = method.label;
507 textWrap.append( labelText );
508
509 // Mark column: will be populated by renderMarks() after the group is in the DOM.
510 const markWrap = document.createElement( 'div' );
511 markWrap.classList.add( 'frm-payment-method-mark' );
512 markWrap.id = `frm-payment-mark-${ key }`;
513
514 const baseUrl = frmPayPalVars.imagesUrl || '';
515
516 if ( key === 'card' ) {
517 const cardBrands = [
518 { file: 'visa.svg', alt: 'Visa' },
519 { file: 'mastercard.svg', alt: 'Mastercard' },
520 { file: 'amex.svg', alt: 'American Express' },
521 { file: 'discover.svg', alt: 'Discover' },
522 ];
523 cardBrands.forEach( function( brand ) {
524 const img = document.createElement( 'img' );
525 img.src = baseUrl + brand.file;
526 img.alt = brand.alt;
527 img.height = 24;
528 markWrap.append( img );
529 } );
530 } else if ( key === 'google_pay' ) {
531 markWrap.classList.add( 'frm-payment-method-google-pay-icon' );
532 const img = document.createElement( 'img' );
533 img.src = `${ baseUrl }gpay.svg`;
534 img.alt = 'Google Pay';
535 img.height = 24;
536 markWrap.append( img );
537 } else if ( key === 'apple_pay' ) {
538 markWrap.classList.add( 'frm-payment-method-apple-pay-icon' );
539 const img = document.createElement( 'img' );
540 img.src = `${ baseUrl }apple-pay.svg`;
541 img.alt = 'Apple Pay';
542 img.height = 24;
543 img.style.width = 'auto';
544 markWrap.append( img );
545 }
546
547 label.append( radio );
548 label.append( textWrap );
549 label.append( markWrap );
550
551 if ( key === 'paylater' ) {
552 // Wrap the label and a message container in a div.
553 const wrapper = document.createElement( 'div' );
554 wrapper.classList.add( 'frm-payment-method-paylater-wrap' );
555 wrapper.append( label );
556
557 const msgContainer = document.createElement( 'div' );
558 msgContainer.id = 'frm-paylater-message';
559 msgContainer.classList.add( 'frm-payment-method-paylater-msg' );
560 wrapper.append( msgContainer );
561
562 return wrapper;
563 }
564
565 return label;
566 }
567
568 /**
569 * Render PayPal Marks into the radio group containers.
570 * Must be called AFTER the radio group is appended to the DOM,
571 * because the Marks API needs the containers to be in the document.
572 */
573 function renderMarks() {
574 if ( 'function' !== typeof paypal.Marks ) {
575 return;
576 }
577
578 for ( const [ key ] of paymentMethods ) {
579 const fundingSource = METHOD_FUNDING_SOURCE[ key ];
580 if ( ! fundingSource ) {
581 continue;
582 }
583
584 const markContainerId = `frm-payment-mark-${ key }`;
585 const container = document.getElementById( markContainerId );
586 if ( ! container ) {
587 continue;
588 }
589
590 try {
591 const mark = paypal.Marks( { fundingSource } );
592 if ( mark.isEligible() ) {
593 mark.render( `#${ markContainerId }` );
594 }
595 } catch ( err ) {
596 // Mark not available for this source, that's fine.
597 }
598 }
599 }
600
601 // ---- Method Selection ----
602
603 /**
604 * Handle switching to a new payment method.
605 *
606 * 1. Lazy-render if this method hasn't been rendered yet.
607 * 2. Hide all method containers.
608 * 3. Show the selected method's container.
609 * 4. Toggle submit button visibility.
610 *
611 * @param {string} key The payment method key to select.
612 */
613 async function selectPaymentMethod( key ) {
614 const method = paymentMethods.get( key );
615 if ( ! method ) {
616 return;
617 }
618
619 selectedMethod = key;
620
621 // Update radio checked state.
622 const radio = document.getElementById( `frm-payment-method-radio-${ key }` );
623 if ( radio && ! radio.checked ) {
624 radio.checked = true;
625 }
626
627 // Lazy-render if this is the first time selecting a non-pre-rendered method.
628 if ( ! method.rendered ) {
629 method.containerEl.innerHTML = '<span class="frm-wait frm_spinner" style="visibility:visible"></span>';
630 try {
631 await method.render();
632 method.rendered = true;
633 } catch ( err ) {
634 console.error( `Failed to render payment method: ${ key }`, err );
635 method.containerEl.innerHTML = '';
636 }
637 }
638
639 // Hide all method containers.
640 for ( const [ , m ] of paymentMethods ) {
641 if ( m.containerEl ) {
642 m.containerEl.style.display = 'none';
643 }
644 }
645
646 // Show the selected one.
647 if ( method.containerEl ) {
648 method.containerEl.style.display = 'block';
649 }
650
651 // Toggle submit button + card fields visibility.
652 updateSubmitButtonVisibility( key );
653
654 // Update active class on radio labels.
655 document.querySelectorAll( '.frm-payment-method-option' ).forEach( el => {
656 el.classList.remove( 'frm-payment-method-active' );
657 } );
658 document.querySelectorAll( '.frm-payment-method-paylater-wrap' ).forEach( el => {
659 el.classList.remove( 'frm-payment-method-active-wrap' );
660 } );
661 const activeLabel = radio?.closest( '.frm-payment-method-option' );
662 if ( activeLabel ) {
663 activeLabel.classList.add( 'frm-payment-method-active' );
664 const wrapper = activeLabel.closest( '.frm-payment-method-paylater-wrap' );
665 if ( wrapper ) {
666 wrapper.classList.add( 'frm-payment-method-active-wrap' );
667 }
668 }
669 }
670
671 /**
672 * Show/hide the native submit button based on the selected method.
673 *
674 * - Card: submit button visible (user fills card fields, clicks submit).
675 * - Everything else: submit button hidden (PayPal SDK button handles submission).
676 *
677 * @param {string} key The selected payment method key.
678 */
679 function updateSubmitButtonVisibility( key ) {
680 const submitButtons = thisForm.querySelectorAll(
681 'input[type="submit"], input[type="button"], button[type="submit"]'
682 );
683 const isCardMethod = key === 'card';
684
685 submitButtons.forEach( btn => {
686 if ( btn.classList.contains( 'frm_prev_page' ) ) {
687 return;
688 }
689
690 if ( isCardMethod ) {
691 btn.style.display = '';
692 if ( cardFieldsValid ) {
693 btn.removeAttribute( 'disabled' );
694 } else {
695 btn.setAttribute( 'disabled', 'disabled' );
696 }
697 } else {
698 btn.style.display = 'none';
699 }
700 } );
701 }
702
703 // ---- Card Fields ----
704
705 /**
706 * Create the PayPal CardFields SDK instance (without rendering).
707 *
708 * @return {Object|null} The card fields instance.
709 */
710 function createCardFieldsSDKInstance() {
711 if ( isRecurring ) {
712 // Credit cards are only supported for one time payments.
713 return null;
714 }
715
716 try {
717 const config = {
718 onError,
719 style: frmPayPalVars.style,
720 inputEvents: {
721 onChange: onCardFieldsChange
722 }
723 };
724
725 config.createOrder = createOrder;
726 config.onApprove = onApprove;
727
728 return window.paypal.CardFields( config );
729 } catch ( err ) {
730 console.error( 'Failed to create CardFields instance', err );
731 return null;
732 }
733 }
734
735 /**
736 * Handle card field value changes.
737 *
738 * @param {Object} data The onChange event data.
739 */
740 function onCardFieldsChange( data ) {
741 cardFieldsValid = data.isFormValid;
742
743 if ( selectedMethod === 'card' ) {
744 if ( cardFieldsValid ) {
745 enableSubmit();
746 } else {
747 disableSubmit( thisForm );
748 }
749 }
750 }
751
752 /**
753 * Render the card number / expiry / CVV fields into the method container.
754 */
755 function renderCardFields() {
756 const method = paymentMethods.get( 'card' );
757 if ( ! method || ! cardFieldsInstance ) {
758 return;
759 }
760
761 const wrapper = document.createElement( 'div' );
762 wrapper.classList.add( 'frm-card-fields-wrapper', 'frm_grid_container' );
763
764 const cardNumberWrapper = document.createElement( 'div' );
765 cardNumberWrapper.id = 'frm-paypal-card-number';
766 cardNumberWrapper.classList.add( 'frm6', 'frm-payment-card-number' );
767
768 const expiryWrapper = document.createElement( 'div' );
769 expiryWrapper.id = 'frm-paypal-card-expiry';
770 expiryWrapper.classList.add( 'frm3', 'frm-payment-card-expiry' );
771
772 const cvvWrapper = document.createElement( 'div' );
773 cvvWrapper.id = 'frm-paypal-card-cvv';
774 cvvWrapper.classList.add( 'frm3', 'frm-payment-card-cvv' );
775
776 wrapper.append( cardNumberWrapper, expiryWrapper, cvvWrapper );
777 method.containerEl.innerHTML = '';
778 method.containerEl.append( wrapper );
779
780 cardFieldsInstance.NumberField().render( '#frm-paypal-card-number' );
781 cardFieldsInstance.ExpiryField().render( '#frm-paypal-card-expiry' );
782 cardFieldsInstance.CVVField().render( '#frm-paypal-card-cvv' );
783
784 setupCardFieldIframeObservers();
785 }
786
787 /**
788 * Watch for PayPal iframe height changes and add 1px to prevent border clipping.
789 */
790 function setupCardFieldIframeObservers() {
791 const ids = [ 'frm-paypal-card-number', 'frm-paypal-card-expiry', 'frm-paypal-card-cvv' ];
792 const wrappers = ids
793 .map( id => document.getElementById( id )?.querySelector( 'iframe' )?.parentNode )
794 .filter( Boolean );
795
796 if ( ! wrappers.length ) {
797 return;
798 }
799
800 const observerOptions = { attributes: true, attributeFilter: [ 'style' ] };
801
802 const observerCallback = mutationsList => {
803 for ( const mutation of mutationsList ) {
804 if ( mutation.type !== 'attributes' || mutation.attributeName !== 'style' ) {
805 continue;
806 }
807
808 const currentHeight = mutation.target.offsetHeight;
809 // Only adjust if height is reasonable and not already adjusted.
810 if ( currentHeight > 40 && currentHeight < 100 ) {
811 mutation.target.style.height = `${ currentHeight + 1 }px`;
812 }
813 }
814 };
815
816 const observer = new MutationObserver( observerCallback );
817 wrappers.forEach( w => observer.observe( w, observerOptions ) );
818 }
819
820 // ---- PayPal Button Creation ----
821
822 /**
823 * Create a PayPal Buttons instance for a given funding source (without rendering).
824 *
825 * @param {string} fundingSource The PayPal FUNDING constant.
826 * @param {boolean} isRecurring Whether this is a recurring payment.
827 *
828 * @return {Object} The PayPal Buttons instance.
829 */
830 function createPayPalButton( fundingSource, isRecurring ) {
831 const buttonConfig = {
832 fundingSource,
833 onApprove,
834 onError,
835 onCancel,
836 style: { ...frmPayPalVars.buttonStyle },
837 };
838
839 const supportedColors = [ 'silver', 'black', 'white' ];
840 const supportedColorsMap = {
841 venmo: [ 'blue' ],
842 paylater: [ 'gold', 'blue' ]
843 };
844
845 supportedColorsMap[ fundingSource ]?.forEach( color => supportedColors.push( color ) );
846
847 if ( ! supportedColors.includes( buttonConfig.style.color ) ) {
848 delete buttonConfig.style.color;
849 }
850
851 if ( isRecurring ) {
852 buttonConfig.createSubscription = createSubscription;
853 } else {
854 buttonConfig.createOrder = createOrder;
855 }
856
857 return paypal.Buttons( buttonConfig );
858 }
859
860 // ---- Google Pay ----
861
862 /**
863 * Check if Google Pay is eligible (without rendering).
864 *
865 * @return {Promise<boolean>} Whether Google Pay is supported and ready to accept payments in the current environment.
866 */
867 async function checkGooglePayEligibility() {
868 if ( 'function' !== typeof paypal.Googlepay ) {
869 return false;
870 }
871
872 if ( 'undefined' === typeof google || google.payments === undefined ) {
873 return false;
874 }
875
876 try {
877 googlePayConfig = await paypal.Googlepay().config();
878 const paymentsClient = getGooglePaymentsClient();
879
880 const readyToPayRequest = Object.assign( {}, googlePayBaseRequest, {
881 allowedPaymentMethods: googlePayConfig.allowedPaymentMethods
882 } );
883
884 const response = await paymentsClient.isReadyToPay( readyToPayRequest );
885 return response.result;
886 } catch ( err ) {
887 console.error( 'Google Pay eligibility check failed', err );
888 return false;
889 }
890 }
891
892 /**
893 * Render the Google Pay button into its method container.
894 */
895 async function renderGooglePayButton() {
896 const method = paymentMethods.get( 'google_pay' );
897 if ( ! method || ! googlePayConfig ) {
898 return;
899 }
900
901 const paymentsClient = getGooglePaymentsClient();
902 const buttonOptions = Object.assign(
903 getGooglePayButtonStyle(),
904 {
905 onClick: () => onGooglePayButtonClicked( googlePayConfig ),
906 allowedPaymentMethods: googlePayConfig.allowedPaymentMethods
907 }
908 );
909 const button = paymentsClient.createButton( buttonOptions );
910
911 const container = method.containerEl;
912 container.innerHTML = '';
913 container.append( button );
914 }
915
916 /**
917 * Get a Google PaymentsClient configured for the current environment.
918 *
919 * @return {google.payments.api.PaymentsClient} The payments client instance.
920 */
921 function getGooglePaymentsClient() {
922 return new google.payments.api.PaymentsClient( {
923 environment: frmPayPalVars.mode === 'test' ? 'TEST' : 'PRODUCTION',
924 paymentDataCallbacks: {
925 onPaymentAuthorized
926 }
927 } );
928 }
929
930 /**
931 * Map frmPayPalVars.buttonStyle to Google Pay ButtonOptions.
932 *
933 * @return {Object} Google Pay button style options.
934 */
935 function getGooglePayButtonStyle() {
936 const style = frmPayPalVars.buttonStyle || {};
937 const options = { buttonSizeMode: 'fill' };
938
939 const colorMap = { black: 'black', white: 'white', silver: 'white' };
940 if ( style.color && colorMap[ style.color ] ) {
941 options.buttonColor = colorMap[ style.color ];
942 }
943
944 const typeMap = { pay: 'pay', checkout: 'checkout', buynow: 'buy', donate: 'donate', subscribe: 'subscribe' };
945 if ( style.label && typeMap[ style.label ] ) {
946 options.buttonType = typeMap[ style.label ];
947 }
948
949 if ( style.borderRadius !== undefined ) {
950 options.buttonRadius = style.borderRadius;
951 }
952
953 return options;
954 }
955
956 /**
957 * Handle Google Pay button click.
958 *
959 * @param {Object} config The config from paypal.Googlepay().config().
960 *
961 * @return {Promise<void>}
962 */
963 async function onGooglePayButtonClicked( config ) {
964 const settings = getPayPalSettings()[ 0 ];
965 const currency = ( settings.currency || 'USD' ).toUpperCase();
966
967 const paymentDataRequest = Object.assign( {}, googlePayBaseRequest );
968 paymentDataRequest.allowedPaymentMethods = config.allowedPaymentMethods;
969 paymentDataRequest.merchantInfo = config.merchantInfo;
970 paymentDataRequest.callbackIntents = [ 'PAYMENT_AUTHORIZATION' ];
971
972 paymentDataRequest.transactionInfo = {
973 currencyCode: currency,
974 totalPriceStatus: 'ESTIMATED',
975 totalPrice: '0.00'
976 };
977
978 try {
979 const amount = await new Promise( ( resolve, reject ) => {
980 getPrice( result => {
981 if ( result?.data?.amount ) {
982 resolve( result.data.amount );
983 } else {
984 reject( new Error( 'No amount' ) );
985 }
986 } );
987 } );
988
989 paymentDataRequest.transactionInfo.totalPrice = String( amount );
990 paymentDataRequest.transactionInfo.totalPriceStatus = 'FINAL';
991 } catch ( e ) {
992 // Fall back to ESTIMATED with 0.00 if we can't get the price.
993 }
994
995 const paymentsClient = getGooglePaymentsClient();
996 paymentsClient.loadPaymentData( paymentDataRequest );
997 }
998
999 /**
1000 * Callback invoked by Google Pay when the buyer authorizes the payment.
1001 *
1002 * @param {Object} paymentData The Google Pay PaymentData response object.
1003 *
1004 * @return {Promise<Object>} Transaction state result for the Google Pay sheet.
1005 */
1006 async function onPaymentAuthorized( paymentData ) {
1007 try {
1008 const orderId = await createOrderForGooglePay();
1009
1010 const confirmOrderResponse = await paypal.Googlepay().confirmOrder( {
1011 orderId,
1012 paymentMethodData: paymentData.paymentMethodData
1013 } );
1014
1015 if ( confirmOrderResponse.status === 'PAYER_ACTION_REQUIRED' ) {
1016 await paypal.Googlepay().initiatePayerAction( { orderId } );
1017 }
1018
1019 if ( confirmOrderResponse.status === 'APPROVED' || confirmOrderResponse.status === 'PAYER_ACTION_REQUIRED' ) {
1020 await onApprove( {
1021 orderID: orderId,
1022 paymentSource: 'google_pay'
1023 } );
1024
1025 return { transactionState: 'SUCCESS' };
1026 }
1027
1028 return {
1029 transactionState: 'ERROR',
1030 error: {
1031 intent: 'PAYMENT_AUTHORIZATION',
1032 message: 'Payment could not be authorized'
1033 }
1034 };
1035 } catch ( err ) {
1036 return {
1037 transactionState: 'ERROR',
1038 error: {
1039 intent: 'PAYMENT_AUTHORIZATION',
1040 message: err.message || 'Payment failed'
1041 }
1042 };
1043 }
1044 }
1045
1046 // ---- Apple Pay ----
1047
1048 /**
1049 * Map frmPayPalVars.buttonStyle to Apple Pay button attributes and CSS custom properties.
1050 *
1051 * The <apple-pay-button> web component uses CSS custom properties for sizing:
1052 * --apple-pay-button-width, --apple-pay-button-height, --apple-pay-button-border-radius,
1053 * --apple-pay-button-padding, --apple-pay-button-box-sizing.
1054 *
1055 * @return {Object} Apple Pay button style options.
1056 */
1057 function getApplePayButtonStyle() {
1058 const style = frmPayPalVars.buttonStyle || {};
1059 const options = {
1060 buttonStyle: 'black',
1061 buttonType: 'buy'
1062 };
1063
1064 const colorMap = {
1065 black: 'black',
1066 white: 'white',
1067 silver: 'white-outline'
1068 };
1069 if ( style.color && colorMap[ style.color ] ) {
1070 options.buttonStyle = colorMap[ style.color ];
1071 }
1072
1073 const typeMap = {
1074 pay: 'pay',
1075 checkout: 'check-out',
1076 buynow: 'buy',
1077 donate: 'donate',
1078 subscribe: 'subscribe',
1079 buy: 'buy'
1080 };
1081 if ( style.label && typeMap[ style.label ] ) {
1082 options.buttonType = typeMap[ style.label ];
1083 }
1084
1085 if ( style.borderRadius !== undefined ) {
1086 options.borderRadius = style.borderRadius;
1087 }
1088
1089 return options;
1090 }
1091
1092 /**
1093 * Render the Apple Pay button into its method container.
1094 *
1095 * The <apple-pay-button> web component uses CSS custom properties for sizing,
1096 * not standard CSS properties or inline styles.
1097 */
1098 async function renderApplePayButton() {
1099 const method = paymentMethods.get( 'apple_pay' );
1100 if ( ! method ) {
1101 return;
1102 }
1103
1104 const container = method.containerEl;
1105 container.innerHTML = '';
1106
1107 // Check if Apple Pay is available on the device.
1108 if ( 'undefined' === typeof ApplePaySession ) {
1109 return;
1110 }
1111
1112 const applePayStyle = getApplePayButtonStyle();
1113
1114 const btn = document.createElement( 'apple-pay-button' );
1115 btn.setAttribute( 'buttonstyle', applePayStyle.buttonStyle );
1116 btn.setAttribute( 'type', applePayStyle.buttonType );
1117 btn.setAttribute( 'locale', 'en' );
1118
1119 // Use CSS custom properties (the only way to style the <apple-pay-button> web component).
1120 btn.style.setProperty( '--apple-pay-button-width', '100%' );
1121 btn.style.setProperty( '--apple-pay-button-height', '40px' );
1122 btn.style.setProperty( '--apple-pay-button-padding', '6px 0' );
1123 btn.style.setProperty( '--apple-pay-button-box-sizing', 'border-box' );
1124
1125 if ( applePayStyle.borderRadius !== undefined ) {
1126 btn.style.setProperty( '--apple-pay-button-border-radius', `${ applePayStyle.borderRadius }px` );
1127 }
1128
1129 btn.addEventListener( 'click', onApplePayButtonClick );
1130 container.append( btn );
1131 }
1132
1133 /**
1134 * Handle click on the Apple Pay button.
1135 *
1136 * ApplePaySession MUST be created and begun synchronously inside the click
1137 * handler. Any async work (like fetching the price) before session.begin()
1138 * causes the user-gesture activation to expire, leaving the payment sheet
1139 * visible but non-interactive.
1140 */
1141 function onApplePayButtonClick() {
1142 if ( ! applePayConfig ) {
1143 console.error( 'Apple Pay config not available' );
1144 return;
1145 }
1146
1147 const paymentRequest = {
1148 countryCode: applePayConfig.countryCode,
1149 merchantCapabilities: applePayConfig.merchantCapabilities,
1150 supportedNetworks: applePayConfig.supportedNetworks,
1151 currencyCode: applePayConfig.currencyCode || 'USD',
1152 requiredBillingContactFields: [ 'postalAddress' ],
1153 total: {
1154 label: document.title || 'Payment',
1155 type: 'final',
1156 amount: cachedAmount,
1157 },
1158 };
1159
1160 const session = new ApplePaySession( 4, paymentRequest );
1161
1162 session.onvalidatemerchant = event => {
1163 applePayInstance.validateMerchant( {
1164 validationUrl: event.validationURL,
1165 displayName: document.title || 'Payment'
1166 } )
1167 .then( validateResult => {
1168 session.completeMerchantValidation( validateResult.merchantSession );
1169 } )
1170 .catch( validateError => {
1171 console.error( 'Apple Pay merchant validation failed', validateError );
1172 session.abort();
1173 } );
1174 };
1175
1176 session.onpaymentmethodselected = () => {
1177 session.completePaymentMethodSelection( {
1178 newTotal: paymentRequest.total,
1179 } );
1180 };
1181
1182 session.onpaymentauthorized = async event => {
1183 let sessionCompleted = false;
1184
1185 try {
1186 const orderId = await createOrderForApplePay();
1187 const confirmOrderResponse = await applePayInstance.confirmOrder( {
1188 orderId,
1189 token: event.payment.token,
1190 billingContact: event.payment.billingContact,
1191 shippingContact: event.payment.shippingContact
1192 } );
1193
1194 const approvalStatus = confirmOrderResponse?.approveApplePayPayment?.status;
1195
1196 if ( approvalStatus === 'PAYER_ACTION_REQUIRED' ) {
1197 await applePayInstance.initiatePayerAction( { orderId } );
1198 }
1199
1200 if ( approvalStatus !== 'APPROVED' && approvalStatus !== 'COMPLETED' ) {
1201 session.completePayment( ApplePaySession.STATUS_FAILURE );
1202 sessionCompleted = true;
1203 return;
1204 }
1205
1206 session.completePayment( ApplePaySession.STATUS_SUCCESS );
1207 sessionCompleted = true;
1208
1209 onApprove( {
1210 orderID: orderId,
1211 paymentSource: 'apple_pay'
1212 } );
1213 } catch ( err ) {
1214 console.error( 'Apple Pay payment failed', err );
1215 if ( ! sessionCompleted ) {
1216 session.completePayment( ApplePaySession.STATUS_FAILURE );
1217 }
1218 reportErrorToServer( err, 'apple_pay' );
1219 }
1220 };
1221
1222 session.oncancel = () => {
1223 onCancel();
1224 };
1225
1226 session.begin();
1227 }
1228
1229 // ---- AJAX / Order Creation ----
1230
1231 /**
1232 * Create a PayPal order via AJAX.
1233 *
1234 * @param {Object} data
1235 * @return {Promise<string>} The order ID.
1236 */
1237 async function createOrder( data ) {
1238 ++running;
1239 thisForm.classList.add( 'frm_loading_form' );
1240
1241 const formData = new FormData( thisForm );
1242 formData.append( 'action', 'frm_paypal_create_order' );
1243 formData.append( 'nonce', frmPayPalVars.nonce );
1244 formData.append( 'payment_source', data.paymentSource );
1245
1246 formData.delete( 'frm_action' );
1247 formData.delete( 'form_key' );
1248 formData.delete( 'item_key' );
1249
1250 const response = await fetch( frmPayPalVars.ajax, {
1251 method: 'POST',
1252 body: formData
1253 } );
1254
1255 if ( ! response.ok ) {
1256 thisForm.classList.remove( 'frm_loading_form' );
1257 throw new Error( 'Failed to create PayPal order' );
1258 }
1259
1260 const orderData = await response.json();
1261
1262 if ( ! orderData.success || ! orderData.data.orderID ) {
1263 thisForm.classList.remove( 'frm_loading_form' );
1264 throwServerError( orderData.data, 'Failed to create PayPal order', 'create_order' );
1265 }
1266
1267 return orderData.data.orderID;
1268 }
1269
1270 async function createSubscription( data ) {
1271 thisForm.classList.add( 'frm_loading_form' );
1272
1273 const formData = new FormData( thisForm );
1274 formData.append( 'action', 'frm_paypal_create_subscription' );
1275 formData.append( 'nonce', frmPayPalVars.nonce );
1276
1277 formData.delete( 'frm_action' );
1278 formData.delete( 'form_key' );
1279 formData.delete( 'item_key' );
1280
1281 const response = await fetch( frmPayPalVars.ajax, {
1282 method: 'POST',
1283 body: formData
1284 } );
1285
1286 if ( ! response.ok ) {
1287 thisForm.classList.remove( 'frm_loading_form' );
1288 const errorData = await response.json();
1289 throwServerError( errorData.data, 'Failed to create PayPal subscription', 'create_subscription' );
1290 }
1291
1292 const orderData = await response.json();
1293
1294 if ( ! orderData.success || ! orderData.data.subscriptionID ) {
1295 thisForm.classList.remove( 'frm_loading_form' );
1296 throwServerError( orderData.data, 'Failed to create PayPal subscription', 'create_subscription' );
1297 }
1298
1299 return orderData.data.subscriptionID;
1300 }
1301
1302 /**
1303 * Create a PayPal order specifically for Google Pay.
1304 *
1305 * @return {Promise<string>} The PayPal order ID.
1306 */
1307 async function createOrderForGooglePay() {
1308 const formData = new FormData( thisForm );
1309 formData.append( 'action', 'frm_paypal_create_order' );
1310 formData.append( 'nonce', frmPayPalVars.nonce );
1311 formData.append( 'payment_source', 'google_pay' );
1312
1313 formData.delete( 'frm_action' );
1314 formData.delete( 'form_key' );
1315 formData.delete( 'item_key' );
1316
1317 const response = await fetch( frmPayPalVars.ajax, {
1318 method: 'POST',
1319 body: formData
1320 } );
1321
1322 if ( ! response.ok ) {
1323 throw new Error( 'Failed to create PayPal order for Google Pay' );
1324 }
1325
1326 const orderData = await response.json();
1327
1328 if ( ! orderData.success || ! orderData.data.orderID ) {
1329 throwServerError( orderData.data, 'Failed to create PayPal order for Google Pay', 'create_order' );
1330 }
1331
1332 return orderData.data.orderID;
1333 }
1334
1335 /**
1336 * Create a PayPal order specifically for Apple Pay.
1337 *
1338 * @return {Promise<string>} The PayPal order ID.
1339 */
1340 async function createOrderForApplePay() {
1341 const formData = new FormData( thisForm );
1342 formData.append( 'action', 'frm_paypal_create_order' );
1343 formData.append( 'nonce', frmPayPalVars.nonce );
1344 formData.append( 'payment_source', 'apple_pay' );
1345
1346 formData.delete( 'frm_action' );
1347 formData.delete( 'form_key' );
1348 formData.delete( 'item_key' );
1349
1350 const response = await fetch( frmPayPalVars.ajax, {
1351 method: 'POST',
1352 body: formData
1353 } );
1354
1355 if ( ! response.ok ) {
1356 throw new Error( 'Failed to create PayPal order for Apple Pay' );
1357 }
1358
1359 const orderData = await response.json();
1360
1361 if ( ! orderData.success || ! orderData.data.orderID ) {
1362 throwServerError( orderData.data, 'Failed to create PayPal order for Apple Pay', 'create_order' );
1363 }
1364
1365 return orderData.data.orderID;
1366 }
1367
1368 // ---- Payment Callbacks ----
1369
1370 /**
1371 * Handle approved payment.
1372 *
1373 * @param {Object} data The approval data containing orderID.
1374 */
1375 async function onApprove( data ) {
1376 if ( 'NO' === data.liabilityShift || 'UNKNOWN' === data.liabilityShift ) {
1377 onError( new Error( 'This payment was flagged as possible fraud and has been rejected.' ) );
1378 return;
1379 }
1380
1381 if ( data.orderID ) {
1382 const orderInput = document.createElement( 'input' );
1383 orderInput.type = 'hidden';
1384 orderInput.name = 'paypal_order_id';
1385 orderInput.value = data.orderID;
1386 thisForm.append( orderInput );
1387 }
1388
1389 if ( data.subscriptionID ) {
1390 const subscriptionInput = document.createElement( 'input' );
1391 subscriptionInput.type = 'hidden';
1392 subscriptionInput.name = 'paypal_subscription_id';
1393 subscriptionInput.value = data.subscriptionID;
1394 thisForm.append( subscriptionInput );
1395 }
1396
1397 const paymentSourceInput = document.createElement( 'input' );
1398 paymentSourceInput.type = 'hidden';
1399 paymentSourceInput.name = 'paypal_payment_source';
1400
1401 // When onApprove is called for card fields, there is no paymentSource specified.
1402 paymentSourceInput.value = data.paymentSource || 'card';
1403
1404 thisForm.append( paymentSourceInput );
1405
1406 // If using the PayPal buttons to submit, there will not be a submitEvent.
1407 if ( ! submitEvent ) {
1408 submitEvent = new Event( 'submit', { cancelable: true, bubbles: true } );
1409 submitEvent.target = thisForm;
1410 }
1411
1412 frmFrontForm.submitFormManual( submitEvent, thisForm );
1413 }
1414
1415 /**
1416 * Handle payment errors.
1417 *
1418 * @param {Error} err The error object.
1419 */
1420 function onError( err ) {
1421 console.error( 'PayPal onError:', err );
1422 running--;
1423 if ( running === 0 && thisForm ) {
1424 if ( selectedMethod === 'card' && cardFieldsValid ) {
1425 enableSubmit();
1426 } else {
1427 frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'disable', 0 );
1428 }
1429 }
1430 reportErrorToServer( err );
1431 }
1432
1433 /**
1434 * Report a PayPal error to the server for logging and permission-aware display.
1435 *
1436 * Posts the error message and debug ID to the server endpoint.
1437 * The server logs the debug ID and returns a display message that
1438 * includes the debug ID only for authorized users.
1439 *
1440 * @param {*} err The error object, string, or PayPal error payload.
1441 * @param {string} context A label for where the error occurred (e.g. 'card_submit').
1442 */
1443 let lastDebugId = '';
1444 let lastContext = '';
1445
1446 function reportErrorToServer( err, context ) {
1447 let errorMessage = extractErrorMessage( err );
1448 // Extract debug ID from error message if it's in {{debug_id:...}} format
1449 let debugId = lastDebugId || ( err?.debugId ? err.debugId : '' );
1450 const debugIdMatch = errorMessage.match( /\{\{debug_id:([^}]+)\}\}/ );
1451 if ( debugIdMatch && debugIdMatch[ 1 ] ) {
1452 debugId = debugIdMatch[ 1 ];
1453 // Remove the debug ID from the error message for display
1454 errorMessage = errorMessage.replace( /\{\{debug_id:[^}]+\}\}/, '' ).trim();
1455 }
1456 const errorContext = context || lastContext || '';
1457 lastDebugId = '';
1458 lastContext = '';
1459
1460 const formData = new FormData();
1461 formData.append( 'action', 'frm_paypal_report_error' );
1462 formData.append( 'nonce', frmPayPalVars.nonce );
1463 formData.append( 'error_message', errorMessage );
1464 formData.append( 'debug_id', debugId );
1465 formData.append( 'context', errorContext );
1466
1467 fetch( frmPayPalVars.ajax, {
1468 method: 'POST',
1469 body: formData
1470 } )
1471 .then( response => response.json() )
1472 .then( result => {
1473 if ( result.success && result.data?.message ) {
1474 displayPaymentFailure( result.data.message );
1475 } else {
1476 displayPaymentFailure( errorMessage );
1477 }
1478 } )
1479 .catch( () => {
1480 displayPaymentFailure( errorMessage );
1481 } );
1482 }
1483
1484 /**
1485 * Throw an Error from a server error response.
1486 *
1487 * @param {*} data The response data from wp_send_json_error.
1488 * @param {string} fallback Fallback message if data is unusable.
1489 * @param {string|undefined} context Current context. Could be 'create_order', 'create_subscription'.
1490 */
1491 function throwServerError( data, fallback, context ) {
1492 let message = fallback;
1493
1494 if ( data && 'object' === typeof data && data.message ) {
1495 message = data.message;
1496 lastDebugId = data.debug_id || '';
1497 } else if ( 'string' === typeof data && data ) {
1498 const debugMatch = data.match( /\{\{debug_id:([^}]+)\}\}/ );
1499 if ( debugMatch ) {
1500 lastDebugId = debugMatch[ 1 ];
1501 message = data.replace( /\{\{debug_id:[^}]+\}\}/, '' ).trim();
1502 } else {
1503 message = data;
1504 }
1505 }
1506
1507 lastContext = context || '';
1508 throw new Error( message );
1509 }
1510
1511 function onCancel() {
1512 thisForm.classList.add( 'frm_loading_form' );
1513 frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'disable', 0 );
1514 }
1515
1516 // ---- Submit Button Helpers ----
1517
1518 /**
1519 * Enable the submit button for the form.
1520 */
1521 function enableSubmit() {
1522 if ( running > 0 ) {
1523 return;
1524 }
1525
1526 thisForm.classList.add( 'frm_loading_form' );
1527 frmFrontForm.removeSubmitLoading( jQuery( thisForm ), 'enable', 0 );
1528
1529 const event = new CustomEvent( 'frmPayPalLiteEnableSubmit', {
1530 detail: { form: thisForm }
1531 } );
1532 document.dispatchEvent( event );
1533 }
1534
1535 /**
1536 * Disable submit button for a target form.
1537 *
1538 * @param {Element} form
1539 * @return {void}
1540 */
1541 function disableSubmit( form ) {
1542 jQuery( form ).find( 'input[type="submit"],input[type="button"],button[type="submit"]' ).not( '.frm_prev_page' ).attr( 'disabled', 'disabled' );
1543
1544 const event = new CustomEvent( 'frmPayPalLiteDisableSubmit', {
1545 detail: { form }
1546 } );
1547 document.dispatchEvent( event );
1548 }
1549
1550 // ---- Error Display ----
1551
1552 /**
1553 * Extract a user-friendly message from a PayPal error.
1554 *
1555 * PayPal CardFields rejections surface a structured payload that includes a
1556 * `details` array with per-field `description` strings (e.g. "Invalid card
1557 * number"). We prefer those over the generic top-level `message` so the
1558 * buyer sees the actionable reason.
1559 *
1560 * @param {*} err The thrown error, string, or PayPal error payload.
1561 * @return {string} A human-readable message.
1562 */
1563 function extractErrorMessage( err ) {
1564 const fallback = 'Payment failed. Please try again.';
1565
1566 if ( ! err ) {
1567 return fallback;
1568 }
1569
1570 if ( 'string' === typeof err ) {
1571 const parsed = parsePayPalErrorString( err );
1572 if ( parsed ) {
1573 return parsed;
1574 }
1575 return mapPayPalErrorCode( err ) || err;
1576 }
1577
1578 // PayPal SDK sometimes nests the payload under `err.data` or `err.response`.
1579 const payloads = [ err, err.data, err.response ].filter( Boolean );
1580 for ( const payload of payloads ) {
1581 const fromDetails = getDescriptionFromDetails( payload.details );
1582 if ( fromDetails ) {
1583 return fromDetails;
1584 }
1585 }
1586
1587 if ( err.message ) {
1588 const parsed = parsePayPalErrorString( err.message );
1589 if ( parsed ) {
1590 return parsed;
1591 }
1592 return mapPayPalErrorCode( err.message ) || err.message;
1593 }
1594
1595 return fallback;
1596 }
1597
1598 /**
1599 * Map PayPal error codes to user-friendly messages.
1600 *
1601 * @param {string} code The PayPal error code (e.g. INVALID_CVV).
1602 * @return {string} The user-friendly message, or empty string if not mapped.
1603 */
1604 function mapPayPalErrorCode( code ) {
1605 const codeMap = {
1606 INVALID_CVV: 'Please enter a valid CVV code.',
1607 INVALID_CARD_NUMBER: 'Please enter a valid card number.',
1608 INVALID_EXPIRY: 'Please enter a valid expiry date.',
1609 };
1610
1611 const upperCode = code.toUpperCase();
1612 return codeMap[ upperCode ] || '';
1613 }
1614
1615 /**
1616 * Extract the first `description` from a PayPal `details` array.
1617 *
1618 * @param {Array} details The PayPal error details array.
1619 * @return {string} The description, or an empty string if none.
1620 */
1621 function getDescriptionFromDetails( details ) {
1622 if ( ! Array.isArray( details ) || ! details.length ) {
1623 return '';
1624 }
1625
1626 for ( const detail of details ) {
1627 if ( detail?.description ) {
1628 return detail.description;
1629 }
1630 }
1631
1632 return '';
1633 }
1634
1635 /**
1636 * Parse a PayPal error string that may contain an embedded JSON payload.
1637 *
1638 * The CardFields SDK can throw errors whose message looks like:
1639 * `Error: ... {"name":"UNPROCESSABLE_ENTITY","details":[...], ...}`.
1640 *
1641 * @param {string} str The raw error string.
1642 * @return {string} The extracted description, or an empty string.
1643 */
1644 function parsePayPalErrorString( str ) {
1645 const start = str.indexOf( '{' );
1646 const end = str.lastIndexOf( '}' );
1647 if ( start === -1 || end === -1 || end <= start ) {
1648 return '';
1649 }
1650
1651 try {
1652 const payload = JSON.parse( str.slice( start, end + 1 ) );
1653 return getDescriptionFromDetails( payload.details );
1654 } catch ( e ) {
1655 return '';
1656 }
1657 }
1658
1659 /**
1660 * Display an error message in the payment form.
1661 *
1662 * @param {string} errorMessage The message to display. May contain a newline-separated
1663 * debug ID line when the server includes it for authorized users.
1664 * @return {void}
1665 */
1666 function displayPaymentFailure( errorMessage ) {
1667 if ( ! thisForm ) {
1668 return;
1669 }
1670
1671 const statusContainer = thisForm.querySelector( '.frm-card-errors' );
1672 if ( statusContainer ) {
1673 statusContainer.textContent = '';
1674
1675 const lines = errorMessage.split( '\n' );
1676 lines.forEach( function( line, index ) {
1677 if ( index > 0 ) {
1678 statusContainer.append( document.createElement( 'br' ) );
1679 }
1680 statusContainer.append( document.createTextNode( line ) );
1681 } );
1682
1683 statusContainer.style.display = 'block';
1684 }
1685 }
1686
1687 /**
1688 * Clear error messages.
1689 */
1690 function clearErrors() {
1691 if ( ! thisForm ) {
1692 return;
1693 }
1694
1695 const statusContainer = thisForm.querySelector( '.frm-card-errors' );
1696 if ( statusContainer ) {
1697 statusContainer.textContent = '';
1698 statusContainer.style.display = 'none';
1699 }
1700 }
1701
1702 // ---- Form Submission ----
1703
1704 /**
1705 * Validate the form before submission.
1706 *
1707 * @param {Element} form
1708 * @return {boolean} True if valid.
1709 */
1710 function validateFormSubmit( form ) {
1711 if ( typeof frmFrontForm.validateFormSubmit !== 'function' ) {
1712 return true;
1713 }
1714
1715 const errors = frmFrontForm.validateFormSubmit( form );
1716 const keys = Object.keys( errors );
1717
1718 if ( 1 === keys.length && errors[ keys[ 0 ] ] === '' ) {
1719 keys.pop();
1720 }
1721
1722 return 0 === keys.length;
1723 }
1724
1725 /**
1726 * Check if the current form action type should trigger payment processing.
1727 *
1728 * @return {boolean} True if current action type should be processed.
1729 */
1730 function currentActionTypeShouldBeProcessed() {
1731 const action = jQuery( thisForm ).find( 'input[name="frm_action"]' ).val();
1732
1733 if ( 'object' !== typeof window.frmProForm || 'function' !== typeof window.frmProForm.currentActionTypeShouldBeProcessed ) {
1734 return 'create' === action;
1735 }
1736
1737 return window.frmProForm.currentActionTypeShouldBeProcessed(
1738 action,
1739 { thisForm }
1740 );
1741 }
1742
1743 /**
1744 * Handle form submission. Routes to card submission when card is selected.
1745 * For button-based methods (PayPal, Venmo, etc.) the SDK handles submission via onApprove.
1746 *
1747 * @param {Event} event
1748 */
1749 async function handleFormSubmission( event ) {
1750 if ( ! currentActionTypeShouldBeProcessed() ) {
1751 return;
1752 }
1753
1754 // Only intercept submission when card is the selected method.
1755 if ( selectedMethod !== 'card' ) {
1756 return;
1757 }
1758
1759 event.preventDefault();
1760 event.stopPropagation();
1761
1762 submitEvent = event;
1763
1764 clearErrors();
1765
1766 thisForm.classList.add( 'frm_js_validate' );
1767 if ( ! validateFormSubmit( thisForm ) ) {
1768 return;
1769 }
1770
1771 disableSubmit( thisForm );
1772
1773 const meta = addName( jQuery( thisForm ) );
1774
1775 const submitArgs = {};
1776
1777 if ( meta.name ) {
1778 submitArgs.cardholderName = meta.name;
1779 }
1780
1781 /*
1782 TODO Add the billing address here as well.
1783 Stripe calls a window.frmProForm.addAddressMeta function.
1784 That's included in frmstrp.js though, so we need to add a script in Pro for PayPal as well.
1785
1786 billingAddress: {
1787 addressLine1: '555 Billing Ave',
1788 adminArea1: 'NY',
1789 adminArea2: 'New York',
1790 postalCode: '10001',
1791 countryCode: 'US'
1792 }
1793 */
1794
1795 try {
1796 await cardFieldsInstance.submit( submitArgs );
1797 } catch ( err ) {
1798 console.error( 'Card fields submit error:', err );
1799 running--;
1800 if ( running === 0 && thisForm ) {
1801 enableSubmit();
1802 }
1803 reportErrorToServer( err, 'card_submit' );
1804 }
1805 }
1806
1807 // ---- Price / Pay Later ----
1808
1809 /**
1810 * Get PayPal settings from frmPayPalVars.settings.
1811 *
1812 * @return {Array} Array of PayPal settings.
1813 */
1814 function getPayPalSettings() {
1815 const paypalSettings = [];
1816 frmPayPalVars.settings.forEach( function( setting ) {
1817 if ( setting.gateways.includes( 'paypal' ) ) {
1818 paypalSettings.push( setting );
1819 }
1820 } );
1821 return paypalSettings;
1822 }
1823
1824 /**
1825 * Get the field IDs that affect the price.
1826 *
1827 * @return {Array} Array of field IDs.
1828 */
1829 function getPriceFields() {
1830 const priceFields = [];
1831 getPayPalSettings().forEach( function( setting ) {
1832 if ( -1 !== setting.fields ) {
1833 setting.fields.forEach( function( field ) {
1834 if ( isNaN( field ) ) {
1835 priceFields.push( `field_${ field }` );
1836 } else {
1837 priceFields.push( field );
1838 }
1839 } );
1840 }
1841 } );
1842 return priceFields;
1843 }
1844
1845 /**
1846 * Handle price field changes.
1847 *
1848 * @param {Event} _ The event object.
1849 * @param {HTMLElement} field The changed field element.
1850 * @param {string} fieldId The changed field ID.
1851 */
1852 function priceChanged( _, field, fieldId ) {
1853 const price = getPriceFields();
1854 let run = price.includes( fieldId ) || price.includes( field.id );
1855
1856 if ( ! run ) {
1857 for ( let i = 0; i < price.length; i++ ) {
1858 if ( field.id.indexOf( price[ i ] ) === 0 ) {
1859 run = true;
1860 break;
1861 }
1862 }
1863 }
1864
1865 if ( ! run ) {
1866 return;
1867 }
1868
1869 const form = field.closest ? field.closest( 'form' ) : jQuery( field ).closest( 'form' )[ 0 ];
1870 if ( ! form ) {
1871 return;
1872 }
1873
1874 getPrice(
1875 function( result ) {
1876 updatePayLaterMessage( result.data.amount );
1877 }
1878 );
1879 }
1880
1881 function getPrice( callback ) {
1882 const formData = new FormData( thisForm );
1883 formData.append( 'action', 'frm_paypal_get_amount' );
1884 formData.append( 'nonce', frmPayPalVars.nonce );
1885
1886 formData.delete( 'frm_action' );
1887 formData.delete( 'form_key' );
1888 formData.delete( 'item_key' );
1889
1890 fetch( frmPayPalVars.ajax, {
1891 method: 'POST',
1892 body: formData
1893 } )
1894 .then( response => response.json() )
1895 .then( function( result ) {
1896 if ( result.success && result.data?.amount ) {
1897 cachedAmount = String( result.data.amount );
1898 callback( result );
1899 }
1900 } )
1901 .catch( function( err ) {
1902 console.error( 'Failed to get PayPal amount', err );
1903 } );
1904 }
1905
1906 /**
1907 * Refresh the cached amount for Apple Pay.
1908 */
1909 function refreshCachedAmount() {
1910 getPrice( function() {} );
1911 }
1912
1913 /**
1914 * Refresh the cached amount when a price-related field changes.
1915 *
1916 * @param {Event} _ jQuery event.
1917 * @param {HTMLElement} field The changed field element.
1918 * @param {string} fieldId The changed field ID.
1919 */
1920 function refreshCachedAmountOnFieldChange( _, field, fieldId ) {
1921 const price = getPriceFields();
1922 let run = price.includes( fieldId ) || price.includes( field.id );
1923
1924 if ( ! run ) {
1925 for ( let i = 0; i < price.length; i++ ) {
1926 if ( field.id.indexOf( price[ i ] ) === 0 ) {
1927 run = true;
1928 break;
1929 }
1930 }
1931 }
1932
1933 if ( run ) {
1934 refreshCachedAmount();
1935 }
1936 }
1937
1938 /**
1939 * Re-render the Pay Later message with the current amount.
1940 *
1941 * @param {number|string} amount
1942 *
1943 * @return {void}
1944 */
1945 function updatePayLaterMessage( amount ) {
1946 const banner = document.getElementById( 'frm-paylater-message' );
1947 if ( banner ) {
1948 banner.setAttribute( 'data-pp-amount', amount );
1949 }
1950 }
1951
1952 function renderMessages() {
1953 if ( 'function' !== typeof paypal.Messages ) {
1954 return;
1955 }
1956
1957 const container = document.getElementById( 'frm-paylater-message' );
1958 if ( ! container ) {
1959 return;
1960 }
1961
1962 getPrice( function( result ) {
1963 container.setAttribute( 'data-pp-amount', result.data.amount );
1964 } );
1965
1966 paypal.Messages( {
1967 style: {
1968 layout: 'text',
1969 logo: { type: 'primary' },
1970 }
1971 } ).render( '#frm-paylater-message' );
1972 }
1973
1974 /**
1975 * Check for price fields on load and trigger an initial price update.
1976 */
1977 function checkPriceFieldsOnLoad() {
1978 getPriceFields().forEach( function( fieldId ) {
1979 const fieldContainer = document.getElementById( `frm_field_${ fieldId }_container` );
1980 if ( ! fieldContainer ) {
1981 return;
1982 }
1983
1984 const input = fieldContainer.querySelector( 'input[name^=item_meta]' );
1985 if ( input && '' !== input.value ) {
1986 priceChanged( null, input, fieldId );
1987 }
1988 } );
1989 }
1990
1991 // ---- Name Fields ----
1992
1993 function addName( $form ) {
1994 let i;
1995 let firstField;
1996 let lastField;
1997 let firstFieldContainer;
1998 let lastFieldContainer;
1999 let firstNameID = '';
2000 let lastNameID = '';
2001 let subFieldEl;
2002
2003 const cardObject = {};
2004 const { settings } = frmPayPalVars;
2005
2006 /**
2007 * Gets first, middle or last name from the given field.
2008 *
2009 * @param {number|HTMLElement} field Field ID or Field element.
2010 * @param {string} subFieldName Subfield name.
2011 * @return {string} Name field value.
2012 */
2013 const getNameFieldValue = function( field, subFieldName ) {
2014 if ( 'object' !== typeof field ) {
2015 field = document.getElementById( `frm_field_${ field }_container` );
2016 }
2017
2018 if ( ! field || 'object' !== typeof field || 'function' !== typeof field.querySelector ) {
2019 return '';
2020 }
2021
2022 subFieldEl = field.querySelector( `.frm_combo_inputs_container .frm_form_subfield-${ subFieldName } input` );
2023 if ( ! subFieldEl ) {
2024 return '';
2025 }
2026
2027 return subFieldEl.value;
2028 };
2029
2030 for ( i = 0; i < settings.length; i++ ) {
2031 firstNameID = settings[ i ].first_name;
2032 lastNameID = settings[ i ].last_name;
2033 }
2034
2035 /**
2036 * Returns a name field container or element.
2037 *
2038 * @param {number} fieldID
2039 * @param {string} type Either 'container' or 'field'
2040 * @param {object|null} $form
2041 * @return {HTMLElement|null} Name field container or element.
2042 */
2043 function getNameFieldItem( fieldID, type, $form = null ) {
2044 const queryForNameFieldIsFound = 'object' === typeof window.frmProForm && 'function' === typeof window.frmProForm.queryForNameField;
2045
2046 if ( type === 'container' ) {
2047 return queryForNameFieldIsFound
2048 ? window.frmProForm.queryForNameField( fieldID, 'container' )
2049 : document.querySelector( `#frm_field_${ fieldID }_container, .frm_field_${ fieldID }_container` );
2050 }
2051
2052 return queryForNameFieldIsFound
2053 ? window.frmProForm.queryForNameField( fieldID, 'field', $form[ 0 ] )
2054 : $form[ 0 ].querySelector( `#frm_field_${ fieldID }_container input, input[name="item_meta[${ fieldID }]"], .frm_field_${ fieldID }_container input` );
2055 }
2056
2057 if ( firstNameID !== '' ) {
2058 firstFieldContainer = getNameFieldItem( firstNameID, 'container' );
2059 if ( firstFieldContainer?.querySelector( '.frm_combo_inputs_container' ) ) {
2060 cardObject.name = getNameFieldValue( firstFieldContainer, 'first' );
2061 } else {
2062 firstField = getNameFieldItem( firstNameID, 'field', $form );
2063 if ( firstField?.value ) {
2064 cardObject.name = firstField.value;
2065 }
2066 }
2067 }
2068
2069 if ( lastNameID !== '' ) {
2070 lastFieldContainer = getNameFieldItem( lastNameID, 'container' );
2071 if ( lastFieldContainer?.querySelector( '.frm_combo_inputs_container' ) ) {
2072 cardObject.name = `${ cardObject.name } ${ getNameFieldValue( lastFieldContainer, 'last' ) }`;
2073 } else {
2074 lastField = getNameFieldItem( lastNameID, 'field', $form );
2075 if ( lastField?.value ) {
2076 cardObject.name = `${ cardObject.name } ${ lastField.value }`;
2077 }
2078 }
2079 }
2080
2081 return cardObject;
2082 }
2083
2084 // ---- Bootstrap ----
2085
2086 document.addEventListener( 'DOMContentLoaded', async function() {
2087 if ( window.paypal ) {
2088 paypalInit();
2089 return;
2090 }
2091
2092 const interval = setInterval(
2093 function() {
2094 if ( window.paypal ) {
2095 paypalInit();
2096 clearInterval( interval );
2097 }
2098 },
2099 50
2100 );
2101 } );
2102
2103 jQuery( document ).on( 'frmPageChanged', function() {
2104 paypalInit();
2105 } );
2106 }() );
2107