PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / trunk
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More vtrunk
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 trunk, at paypal/js/frontend.js

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