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
← All changes | paypal/js/frontend.js +395 -210 6.31 → trunk View file →
@@ -130,9 +130,13 @@
130 130
131 131 // Clear the card element. We rebuild it entirely.
132 132 cardElement.innerHTML = '';
133 133
134 - // 1. Discover eligible methods and register them.
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).
135 139 await discoverPaymentMethods( {
136 140 cardFieldsAreSupported,
137 141 buttonsAreEnabled,
138 142 isRecurring
@@ -137,8 +141,15 @@
137 141 buttonsAreEnabled,
138 142 isRecurring
139 143 } );
140 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 +
141 152 if ( paymentMethods.size === 0 ) {
142 153 displayPaymentFailure( 'No payment methods available.' );
143 154 return;
144 155 }
@@ -143,9 +154,13 @@
143 154 return;
144 155 }
145 156
146 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.
147 159 const radioGroup = buildRadioGroup();
160 + if ( paymentMethods.size === 1 ) {
161 + radioGroup.style.display = 'none';
162 + }
148 163 cardElement.append( radioGroup );
149 164 renderMarks();
150 165
151 166 // 3. Build a container area for each method's UI (buttons / card fields).
@@ -207,9 +222,10 @@
207 222 async function discoverPaymentMethods( opts ) {
208 223 const { cardFieldsAreSupported, buttonsAreEnabled, isRecurring } = opts;
209 224
210 225 // --- Card Fields ---
211 - if ( cardFieldsAreSupported ) {
226 + // Card fields are not supported for recurring payments
227 + if ( cardFieldsAreSupported && ! isRecurring ) {
212 228 const cardFields = createCardFieldsSDKInstance();
213 229 if ( cardFields?.isEligible() ) {
214 230 cardFieldsInstance = cardFields;
215 231 registerMethod( 'card', {
@@ -262,29 +278,166 @@
262 278 }
263 279 }
264 280 }
265 281
266 - // --- Google Pay ---
267 - if ( buttonsAreEnabled && ! isRecurring ) {
268 - const googlePayEligible = await checkGooglePayEligibility();
269 - if ( googlePayEligible ) {
270 - registerMethod( 'google_pay', {
271 - eligible: true,
272 - render: renderGooglePayButton
273 - } );
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;
274 393 }
394 + } );
395 +
396 + if ( ! configReady ) {
397 + return false;
275 398 }
276 399
277 - // --- Apple Pay ---
278 - if ( buttonsAreEnabled && ! isRecurring ) {
279 - const applePayEligibilityResult = await checkApplePayEligibility();
280 - if ( applePayEligibilityResult === '' ) {
281 - registerMethod( 'apple_pay', {
282 - eligible: true,
283 - render: renderApplePayButton
284 - } );
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;
285 420 }
421 + return result;
422 + };
423 +
424 + if ( await checkPredicate() ) {
425 + return true;
286 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 + } );
287 440 }
288 441
289 442 /**
290 443 * Register a payment method in the registry.
@@ -318,89 +471,101 @@
318 471 group.setAttribute( 'role', 'radiogroup' );
319 472 group.setAttribute( 'aria-label', 'Select payment method' );
320 473
321 474 for ( const [ key, method ] of paymentMethods ) {
322 - const label = document.createElement( 'label' );
323 - label.classList.add( 'frm-payment-method-option' );
324 - label.setAttribute( 'for', `frm-payment-method-radio-${ key }` );
475 + group.append( buildMethodOption( key, method ) );
476 + }
325 477
326 - const radio = document.createElement( 'input' );
327 - radio.type = 'radio';
328 - radio.name = 'frm_payment_method';
329 - radio.id = `frm-payment-method-radio-${ key }`;
330 - radio.value = key;
478 + return group;
479 + }
331 480
332 - radio.addEventListener( 'change', () => selectPaymentMethod( key ) );
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 }` );
333 493
334 - // Text column: label + description.
335 - const textWrap = document.createElement( 'div' );
336 - textWrap.classList.add( 'frm-payment-method-text' );
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;
337 499
338 - const labelText = document.createElement( 'span' );
339 - labelText.classList.add( 'frm-payment-method-label-text' );
340 - labelText.textContent = method.label;
341 - textWrap.append( labelText );
500 + radio.addEventListener( 'change', () => selectPaymentMethod( key ) );
342 501
343 - // Mark column: will be populated by renderMarks() after the group is in the DOM.
344 - const markWrap = document.createElement( 'div' );
345 - markWrap.classList.add( 'frm-payment-method-mark' );
346 - markWrap.id = `frm-payment-mark-${ key }`;
502 + // Text column: label + description.
503 + const textWrap = document.createElement( 'div' );
504 + textWrap.classList.add( 'frm-payment-method-text' );
347 505
348 - const baseUrl = frmPayPalVars.imagesUrl || '';
506 + const labelText = document.createElement( 'span' );
507 + labelText.classList.add( 'frm-payment-method-label-text' );
508 + labelText.textContent = method.label;
509 + textWrap.append( labelText );
349 510
350 - if ( key === 'card' ) {
351 - const cardBrands = [
352 - { file: 'visa.svg', alt: 'Visa' },
353 - { file: 'mastercard.svg', alt: 'Mastercard' },
354 - { file: 'amex.svg', alt: 'American Express' },
355 - { file: 'discover.svg', alt: 'Discover' },
356 - ];
357 - cardBrands.forEach( function( brand ) {
358 - const img = document.createElement( 'img' );
359 - img.src = baseUrl + brand.file;
360 - img.alt = brand.alt;
361 - img.height = 24;
362 - markWrap.append( img );
363 - } );
364 - } else if ( key === 'google_pay' ) {
365 - markWrap.classList.add( 'frm-payment-method-google-pay-icon' );
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 ) {
366 526 const img = document.createElement( 'img' );
367 - img.src = `${ baseUrl }gpay.svg`;
368 - img.alt = 'Google Pay';
527 + img.src = baseUrl + brand.file;
528 + img.alt = brand.alt;
369 529 img.height = 24;
370 530 markWrap.append( img );
371 - } else if ( key === 'apple_pay' ) {
372 - markWrap.classList.add( 'frm-payment-method-apple-pay-icon' );
373 - const img = document.createElement( 'img' );
374 - img.src = `${ baseUrl }apple-pay.svg`;
375 - img.alt = 'Apple Pay';
376 - img.height = 24;
377 - img.style.width = 'auto';
378 - markWrap.append( img );
379 - }
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 + }
380 548
381 - label.append( radio );
382 - label.append( textWrap );
383 - label.append( markWrap );
549 + label.append( radio );
550 + label.append( textWrap );
551 + label.append( markWrap );
384 552
385 - if ( key === 'paylater' ) {
386 - // Wrap the label and a message container in a div.
387 - const wrapper = document.createElement( 'div' );
388 - wrapper.classList.add( 'frm-payment-method-paylater-wrap' );
389 - wrapper.append( label );
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 );
390 558
391 - const msgContainer = document.createElement( 'div' );
392 - msgContainer.id = 'frm-paylater-message';
393 - msgContainer.classList.add( 'frm-payment-method-paylater-msg' );
394 - wrapper.append( msgContainer );
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 );
395 563
396 - group.append( wrapper );
397 - } else {
398 - group.append( label );
399 - }
564 + return wrapper;
400 565 }
401 566
402 - return group;
567 + return label;
403 568 }
404 569
405 570 /**
406 571 * Render PayPal Marks into the radio group containers.
@@ -544,8 +709,13 @@
544 709 *
545 710 * @return {Object|null} The card fields instance.
546 711 */
547 712 function createCardFieldsSDKInstance() {
713 + if ( isRecurring ) {
714 + // Credit cards are only supported for one time payments.
715 + return null;
716 + }
717 +
548 718 try {
549 719 const config = {
550 720 onError,
551 721 style: frmPayPalVars.style,
@@ -553,15 +723,10 @@
553 723 onChange: onCardFieldsChange
554 724 }
555 725 };
556 726
557 - if ( isRecurring ) {
558 - config.createVaultSetupToken = createVaultSetupToken;
559 - config.onApprove = onVaultApprove;
560 - } else {
561 - config.createOrder = createOrder;
562 - config.onApprove = onApprove;
563 - }
727 + config.createOrder = createOrder;
728 + config.onApprove = onApprove;
564 729
565 730 return window.paypal.CardFields( config );
566 731 } catch ( err ) {
567 732 console.error( 'Failed to create CardFields instance', err );
@@ -635,11 +800,9 @@
635 800 }
636 801
637 802 const observerOptions = { attributes: true, attributeFilter: [ 'style' ] };
638 803
639 - const observerCallback = ( mutationsList, observer ) => {
640 - observer.disconnect();
641 -
804 + const observerCallback = mutationsList => {
642 805 for ( const mutation of mutationsList ) {
643 806 if ( mutation.type !== 'attributes' || mutation.attributeName !== 'style' ) {
644 807 continue;
645 808 }
@@ -644,14 +807,13 @@
644 807 continue;
645 808 }
646 809
647 810 const currentHeight = mutation.target.offsetHeight;
648 - if ( currentHeight > 0 ) {
811 + // Only adjust if height is reasonable and not already adjusted.
812 + if ( currentHeight > 40 && currentHeight < 100 ) {
649 813 mutation.target.style.height = `${ currentHeight + 1 }px`;
650 814 }
651 815 }
652 -
653 - wrappers.forEach( w => observer.observe( w, observerOptions ) );
654 816 };
655 817
656 818 const observer = new MutationObserver( observerCallback );
657 819 wrappers.forEach( w => observer.observe( w, observerOptions ) );
@@ -759,9 +921,9 @@
759 921 * @return {google.payments.api.PaymentsClient} The payments client instance.
760 922 */
761 923 function getGooglePaymentsClient() {
762 924 return new google.payments.api.PaymentsClient( {
763 - environment: 'TEST',
925 + environment: frmPayPalVars.mode === 'test' ? 'TEST' : 'PRODUCTION',
764 926 paymentDataCallbacks: {
765 927 onPaymentAuthorized
766 928 }
767 929 } );
@@ -929,41 +1091,8 @@
929 1091 return options;
930 1092 }
931 1093
932 1094 /**
933 - * Check if Apple Pay is eligible (without rendering).
934 - *
935 - * @return {Promise<string>} An empty string if Apple Pay is supported and ready to accept payments in the current environment, or a string with the reason for ineligibility.
936 - */
937 - async function checkApplePayEligibility() {
938 - if ( 'function' !== typeof paypal.Applepay ) {
939 - return 'PayPal Apple Pay SDK not loaded';
940 - }
941 -
942 - if ( ! window.ApplePaySession ) {
943 - return 'Not on Apple device';
944 - }
945 -
946 - if ( ! ApplePaySession.canMakePayments() ) {
947 - return 'Apple Pay not configured on device';
948 - }
949 -
950 - // Use paypal.Applepay().config() as the definitive eligibility check (per PayPal multiparty docs).
951 - try {
952 - applePayInstance = paypal.Applepay();
953 - applePayConfig = await applePayInstance.config();
954 -
955 - if ( ! applePayConfig || ! applePayConfig.isEligible ) {
956 - return 'PayPal reports Apple Pay is not eligible for this merchant/domain';
957 - }
958 - } catch ( err ) {
959 - return `Apple Pay config check failed: ${ err.message }`;
960 - }
961 -
962 - return '';
963 - }
964 -
965 - /**
966 1095 * Render the Apple Pay button into its method container.
967 1096 *
968 1097 * The <apple-pay-button> web component uses CSS custom properties for sizing,
969 1098 * not standard CSS properties or inline styles.
@@ -976,8 +1105,13 @@
976 1105
977 1106 const container = method.containerEl;
978 1107 container.innerHTML = '';
979 1108
1109 + // Check if Apple Pay is available on the device.
1110 + if ( 'undefined' === typeof ApplePaySession ) {
1111 + return;
1112 + }
1113 +
980 1114 const applePayStyle = getApplePayButtonStyle();
981 1115
982 1116 const btn = document.createElement( 'apple-pay-button' );
983 1117 btn.setAttribute( 'buttonstyle', applePayStyle.buttonStyle );
@@ -1064,8 +1198,14 @@
1064 1198 if ( approvalStatus === 'PAYER_ACTION_REQUIRED' ) {
1065 1199 await applePayInstance.initiatePayerAction( { orderId } );
1066 1200 }
1067 1201
1202 + if ( approvalStatus !== 'APPROVED' && approvalStatus !== 'COMPLETED' ) {
1203 + session.completePayment( ApplePaySession.STATUS_FAILURE );
1204 + sessionCompleted = true;
1205 + return;
1206 + }
1207 +
1068 1208 session.completePayment( ApplePaySession.STATUS_SUCCESS );
1069 1209 sessionCompleted = true;
1070 1210
1071 1211 onApprove( {
@@ -1226,73 +1366,11 @@
1226 1366
1227 1367 return orderData.data.orderID;
1228 1368 }
1229 1369
1230 - async function createVaultSetupToken() {
1231 - const formData = new FormData( thisForm );
1232 - formData.append( 'action', 'frm_paypal_create_vault_setup_token' );
1233 - formData.append( 'nonce', frmPayPalVars.nonce );
1234 - formData.append( 'payment_source', 'card' );
1235 -
1236 - formData.delete( 'frm_action' );
1237 - formData.delete( 'form_key' );
1238 - formData.delete( 'item_key' );
1239 -
1240 - const response = await fetch( frmPayPalVars.ajax, {
1241 - method: 'POST',
1242 - body: formData
1243 - } );
1244 -
1245 - if ( ! response.ok ) {
1246 - throw new Error( 'Failed to create PayPal vault setup token' );
1247 - }
1248 -
1249 - const tokenData = await response.json();
1250 -
1251 - if ( ! tokenData.success || ! tokenData.data.token ) {
1252 - console.error( 'Vault setup token response:', tokenData );
1253 - throwServerError( tokenData.data, 'Failed to create PayPal vault setup token', 'create_vault_token' );
1254 - }
1255 -
1256 - return tokenData.data.token;
1257 - }
1258 -
1259 1370 // ---- Payment Callbacks ----
1260 1371
1261 1372 /**
1262 - * Handle vault approval for card field subscriptions.
1263 - * Receives the vaultSetupToken, sends it to the server to create
1264 - * a payment token and subscription, then submits the form.
1265 - *
1266 - * @param {Object} data The approval data containing vaultSetupToken.
1267 - */
1268 - async function onVaultApprove( data ) {
1269 - if ( 'NO' === data.liabilityShift || 'UNKNOWN' === data.liabilityShift ) {
1270 - onError( new Error( 'This payment was flagged as possible fraud and has been rejected.' ) );
1271 - return;
1272 - }
1273 -
1274 - try {
1275 - let vaultInput = thisForm.querySelector( 'input[name="vault_setup_token"]' );
1276 - if ( ! vaultInput ) {
1277 - vaultInput = document.createElement( 'input' );
1278 - vaultInput.type = 'hidden';
1279 - vaultInput.name = 'vault_setup_token';
1280 - thisForm.append( vaultInput );
1281 - }
1282 - vaultInput.value = data.vaultSetupToken;
1283 -
1284 - const subscriptionID = await createSubscription( data );
1285 - await onApprove( {
1286 - subscriptionID,
1287 - paymentSource: 'card'
1288 - } );
1289 - } catch ( err ) {
1290 - onError( err );
1291 - }
1292 - }
1293 -
1294 - /**
1295 1373 * Handle approved payment.
1296 1374 *
1297 1375 * @param {Object} data The approval data containing orderID.
1298 1376 */
@@ -1326,18 +1404,15 @@
1326 1404 paymentSourceInput.value = data.paymentSource || 'card';
1327 1405
1328 1406 thisForm.append( paymentSourceInput );
1329 1407
1408 + // If using the PayPal buttons to submit, there will not be a submitEvent.
1330 1409 if ( ! submitEvent ) {
1331 1410 submitEvent = new Event( 'submit', { cancelable: true, bubbles: true } );
1332 1411 submitEvent.target = thisForm;
1333 1412 }
1334 1413
1335 - if ( typeof frmFrontForm.submitFormManual === 'function' ) {
1336 - frmFrontForm.submitFormManual( submitEvent, thisForm );
1337 - } else {
1338 - thisForm.submit();
1339 - }
1414 + frmFrontForm.submitFormManual( submitEvent, thisForm );
1340 1415 }
1341 1416
1342 1417 /**
1343 1418 * Handle payment errors.
@@ -1494,9 +1569,13 @@
1494 1569 return fallback;
1495 1570 }
1496 1571
1497 1572 if ( 'string' === typeof err ) {
1498 - return parsePayPalErrorString( err ) || err;
1573 + const parsed = parsePayPalErrorString( err );
1574 + if ( parsed ) {
1575 + return parsed;
1576 + }
1577 + return mapPayPalErrorCode( err ) || err;
1499 1578 }
1500 1579
1501 1580 // PayPal SDK sometimes nests the payload under `err.data` or `err.response`.
1502 1581 const payloads = [ err, err.data, err.response ].filter( Boolean );
@@ -1507,9 +1586,13 @@
1507 1586 }
1508 1587 }
1509 1588
1510 1589 if ( err.message ) {
1511 - return parsePayPalErrorString( err.message ) || err.message;
1590 + const parsed = parsePayPalErrorString( err.message );
1591 + if ( parsed ) {
1592 + return parsed;
1593 + }
1594 + return mapPayPalErrorCode( err.message ) || err.message;
1512 1595 }
1513 1596
1514 1597 return fallback;
1515 1598 }
@@ -1514,8 +1597,25 @@
1514 1597 return fallback;
1515 1598 }
1516 1599
1517 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 + /**
1518 1618 * Extract the first `description` from a PayPal `details` array.
1519 1619 *
1520 1620 * @param {Array} details The PayPal error details array.
1521 1621 * @return {string} The description, or an empty string if none.
@@ -1679,21 +1779,13 @@
1679 1779 if ( meta.name ) {
1680 1780 submitArgs.cardholderName = meta.name;
1681 1781 }
1682 1782
1683 - /*
1684 - TODO Add the billing address here as well.
1685 - Stripe calls a window.frmProForm.addAddressMeta function.
1686 - That's included in frmstrp.js though, so we need to add a script in Pro for PayPal as well.
1783 + const billingAddress = getBillingAddress();
1687 1784
1688 - billingAddress: {
1689 - addressLine1: '555 Billing Ave',
1690 - adminArea1: 'NY',
1691 - adminArea2: 'New York',
1692 - postalCode: '10001',
1693 - countryCode: 'US'
1785 + if ( billingAddress ) {
1786 + submitArgs.billingAddress = billingAddress;
1694 1787 }
1695 - */
1696 1788
1697 1789 try {
1698 1790 await cardFieldsInstance.submit( submitArgs );
1699 1791 } catch ( err ) {
@@ -1703,8 +1795,101 @@
1703 1795 enableSubmit();
1704 1796 }
1705 1797 reportErrorToServer( err, 'card_submit' );
1706 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' : '';
1707 1892 }
1708 1893
1709 1894 // ---- Price / Pay Later ----
1710 1895