PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 1.7.0.9 All 211 releases
charitable / assets / js / paypal-commerce.js

paypal-commerce.js in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at assets/js/paypal-commerce.js

1,824 lines 58.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Charitable PayPal Commerce Gateway
3 *
4 * Handles PayPal JS SDK integration for donation forms.
5 * Supports PayPal Buttons and Card Fields (ACDC).
6 *
7 * @package Charitable/Gateways/PayPal Commerce
8 * @since 1.9.0
9 */
10
11 ( function( $ ) {
12 'use strict';
13
14 // Diagnostic logger. Silent unless charitable_is_debug( 'paypal' ) is true on the PHP side.
15 function debugLog() {
16 if ( typeof CHARITABLE_PAYPAL_COMMERCE === 'undefined' || ! CHARITABLE_PAYPAL_COMMERCE.debug ) {
17 return;
18 }
19 console.log.apply( console, arguments );
20 }
21
22 var CharitablePayPalCommerce = {
23
24 /**
25 * Configuration from localized script.
26 */
27 config: {},
28
29 /**
30 * Current donation form.
31 */
32 $form: null,
33
34 /**
35 * PayPal buttons instance.
36 */
37 buttonsInstance: null,
38
39 /**
40 * Card Fields instance.
41 */
42 cardFieldsInstance: null,
43
44 /**
45 * Apple Pay instance.
46 */
47 applePayInstance: null,
48
49 /**
50 * Google Pay instance.
51 */
52 googlePayInstance: null,
53
54 /**
55 * Fastlane instance.
56 */
57 fastlaneInstance: null,
58
59 /**
60 * Fastlane payment component instance.
61 */
62 fastlanePaymentComponent: null,
63
64 /**
65 * Client token for Fastlane.
66 */
67 clientToken: null,
68
69 /**
70 * Whether buttons are rendered.
71 */
72 buttonsRendered: false,
73
74 /**
75 * Whether card fields are rendered.
76 */
77 cardFieldsRendered: false,
78
79 /**
80 * Whether Apple Pay is rendered.
81 */
82 applePayRendered: false,
83
84 /**
85 * Whether Google Pay is rendered.
86 */
87 googlePayRendered: false,
88
89 /**
90 * Whether Fastlane is rendered.
91 */
92 fastlaneRendered: false,
93
94 /**
95 * Currently selected payment method ('paypal' or 'card').
96 */
97 selectedPaymentMethod: 'paypal',
98
99 /**
100 * Initialize.
101 */
102 init: function() {
103 if ( typeof CHARITABLE_PAYPAL_COMMERCE === 'undefined' ) {
104 console.error( 'Charitable PayPal Commerce: Config not found.' );
105 return;
106 }
107
108 this.config = CHARITABLE_PAYPAL_COMMERCE;
109
110 // Wait for PayPal SDK to be ready.
111 if ( typeof paypal === 'undefined' ) {
112 console.error( 'Charitable PayPal Commerce: PayPal SDK not loaded.' );
113 return;
114 }
115
116 // Initialize on donation forms.
117 this.initForms();
118
119 // Listen for gateway changes.
120 $( document ).on( 'change', 'input[name="gateway"]', this.onGatewayChange.bind( this ) );
121
122 // Listen for payment method tab changes.
123 $( document ).on( 'click', '.charitable-paypal-payment-method-tab', this.onPaymentMethodTabClick.bind( this ) );
124
125 // Listen for form updates (e.g., after AJAX).
126 $( document ).on( 'charitable:form:updated', this.onFormUpdated.bind( this ) );
127 },
128
129 /**
130 * Initialize PayPal on all donation forms.
131 */
132 initForms: function() {
133 var self = this;
134
135 $( '.charitable-donation-form' ).each( function() {
136 var $form = $( this );
137 var $container = $form.find( '#charitable-paypal-commerce-container' );
138
139 if ( $container.length && ! $container.data( 'initialized' ) ) {
140 self.initForm( $form, $container );
141 }
142 } );
143 },
144
145 /**
146 * Initialize PayPal on a specific form.
147 *
148 * @param {jQuery} $form Form element.
149 * @param {jQuery} $container PayPal container element.
150 */
151 initForm: function( $form, $container ) {
152 var self = this;
153 this.$form = $form;
154
155 // Check if PayPal Commerce is the selected gateway.
156 // Match either a :checked radio (multi-gateway picker) or a hidden input
157 // (single-gateway mode where Charitable emits the gateway as type=hidden).
158 // Without the hidden-input branch, single-gateway legacy donation forms
159 // never identify the active gateway and the SDK buttons never render.
160 var $gw = $form.find( 'input[name="gateway"]:checked, input[name="gateway"][type="hidden"]' );
161 var selectedGateway = $gw.length ? $gw.val() : '';
162
163 if ( selectedGateway !== this.config.gateway_id ) {
164 $container.hide();
165 return;
166 }
167
168 $container.show();
169 $container.data( 'initialized', true );
170
171 // Hide the default submit button when PayPal is selected.
172 this.toggleSubmitButton( false );
173
174 // Build the payment UI.
175 this.buildPaymentUI( $container );
176 },
177
178 /**
179 * Build the payment UI with tabs for PayPal buttons and Card Fields.
180 *
181 * @param {jQuery} $container Container element.
182 */
183 buildPaymentUI: function( $container ) {
184 var self = this;
185 var hasCardFields = this.config.card_fields && this.config.card_fields.enabled;
186 var hasApplePay = this.config.apple_pay && this.config.apple_pay.enabled;
187 var hasGooglePay = this.config.google_pay && this.config.google_pay.enabled;
188 var hasMultipleMethods = hasCardFields || hasApplePay || hasGooglePay;
189
190 // Clear existing content.
191 $container.empty();
192
193 // Create the UI structure.
194 var $wrapper = $( '<div class="charitable-paypal-payment-wrapper"></div>' );
195
196 if ( hasMultipleMethods ) {
197 // Create tabs for different payment methods.
198 var tabsHtml = '<div class="charitable-paypal-payment-tabs">';
199
200 // PayPal tab (always first).
201 tabsHtml += '<button type="button" class="charitable-paypal-payment-method-tab active" data-method="paypal">' +
202 '<span class="tab-icon paypal-icon"></span>' +
203 '<span class="tab-label">PayPal</span>' +
204 '</button>';
205
206 // Card fields tab.
207 if ( hasCardFields ) {
208 tabsHtml += '<button type="button" class="charitable-paypal-payment-method-tab" data-method="card">' +
209 '<span class="tab-icon card-icon"></span>' +
210 '<span class="tab-label">' + ( this.config.i18n.pay_with_card || 'Pay with Card' ) + '</span>' +
211 '</button>';
212 }
213
214 // Apple Pay tab.
215 if ( hasApplePay ) {
216 tabsHtml += '<button type="button" class="charitable-paypal-payment-method-tab" data-method="applepay">' +
217 '<span class="tab-icon apple-pay-icon"></span>' +
218 '<span class="tab-label">Apple Pay</span>' +
219 '</button>';
220 }
221
222 // Google Pay tab.
223 if ( hasGooglePay ) {
224 tabsHtml += '<button type="button" class="charitable-paypal-payment-method-tab" data-method="googlepay">' +
225 '<span class="tab-icon google-pay-icon"></span>' +
226 '<span class="tab-label">Google Pay</span>' +
227 '</button>';
228 }
229
230 tabsHtml += '</div>';
231 var $tabs = $( tabsHtml );
232 $wrapper.append( $tabs );
233 }
234
235 // PayPal buttons container.
236 var $buttonsPane = $( '<div class="charitable-paypal-pane charitable-paypal-buttons-pane active" data-method="paypal"></div>' );
237 $wrapper.append( $buttonsPane );
238
239 if ( hasCardFields ) {
240 // Card fields container.
241 var $cardPane = $( '<div class="charitable-paypal-pane charitable-paypal-card-pane" data-method="card"></div>' );
242
243 // Card fields form structure.
244 var $cardForm = $(
245 '<div class="charitable-paypal-card-form">' +
246 '<div class="charitable-paypal-card-field-wrapper">' +
247 '<label for="charitable-card-number">' + ( this.config.i18n.card_number || 'Card Number' ) + '</label>' +
248 '<div id="charitable-card-number" class="charitable-paypal-card-field"></div>' +
249 '</div>' +
250 '<div class="charitable-paypal-card-row">' +
251 '<div class="charitable-paypal-card-field-wrapper charitable-paypal-card-expiry">' +
252 '<label for="charitable-card-expiry">' + ( this.config.i18n.expiry || 'Expiry Date' ) + '</label>' +
253 '<div id="charitable-card-expiry" class="charitable-paypal-card-field"></div>' +
254 '</div>' +
255 '<div class="charitable-paypal-card-field-wrapper charitable-paypal-card-cvv">' +
256 '<label for="charitable-card-cvv">' + ( this.config.i18n.cvv || 'CVV' ) + '</label>' +
257 '<div id="charitable-card-cvv" class="charitable-paypal-card-field"></div>' +
258 '</div>' +
259 '</div>' +
260 '<div class="charitable-paypal-card-field-wrapper">' +
261 '<label for="charitable-card-name">' + ( this.config.i18n.card_name || 'Name on Card' ) + '</label>' +
262 '<input type="text" id="charitable-card-name" class="charitable-paypal-card-name-input" placeholder="John Doe" />' +
263 '</div>' +
264 '<div class="charitable-paypal-card-submit-wrapper">' +
265 '<button type="button" id="charitable-card-submit" class="charitable-paypal-card-submit button">' +
266 '<span class="button-text">' + ( this.config.i18n.donate || 'Donate' ) + '</span>' +
267 '</button>' +
268 '</div>' +
269 '<div class="charitable-paypal-card-errors"></div>' +
270 '</div>'
271 );
272
273 $cardPane.append( $cardForm );
274 $wrapper.append( $cardPane );
275
276 // Add card submit handler.
277 $cardForm.find( '#charitable-card-submit' ).on( 'click', function( e ) {
278 e.preventDefault();
279 self.submitCardPayment();
280 } );
281 }
282
283 // Apple Pay container.
284 if ( hasApplePay ) {
285 var $applePayPane = $( '<div class="charitable-paypal-pane charitable-paypal-applepay-pane" data-method="applepay">' +
286 '<div id="charitable-apple-pay-container" class="charitable-apple-pay-container"></div>' +
287 '</div>' );
288 $wrapper.append( $applePayPane );
289 }
290
291 // Google Pay container.
292 if ( hasGooglePay ) {
293 var $googlePayPane = $( '<div class="charitable-paypal-pane charitable-paypal-googlepay-pane" data-method="googlepay">' +
294 '<div id="charitable-google-pay-container" class="charitable-google-pay-container"></div>' +
295 '</div>' );
296 $wrapper.append( $googlePayPane );
297 }
298
299 $container.append( $wrapper );
300
301 // Render PayPal buttons.
302 this.renderButtons( $buttonsPane );
303
304 // Render Card Fields if available.
305 if ( hasCardFields ) {
306 this.renderCardFields();
307 }
308
309 // Render Apple Pay if available.
310 if ( hasApplePay ) {
311 this.renderApplePay();
312 }
313
314 // Render Google Pay if available.
315 if ( hasGooglePay ) {
316 this.renderGooglePay();
317 }
318
319 // Initialize Fastlane if available.
320 if ( this.shouldShowFastlane() ) {
321 this.initFastlane();
322 }
323 },
324
325 /**
326 * Handle payment method tab click.
327 *
328 * @param {Event} e Click event.
329 */
330 onPaymentMethodTabClick: function( e ) {
331 e.preventDefault();
332 var $tab = $( e.currentTarget );
333 var method = $tab.data( 'method' );
334
335 if ( method === this.selectedPaymentMethod ) {
336 return;
337 }
338
339 this.selectedPaymentMethod = method;
340
341 // Update tab states.
342 $tab.siblings().removeClass( 'active' );
343 $tab.addClass( 'active' );
344
345 // Update pane visibility.
346 var $container = $tab.closest( '#charitable-paypal-commerce-container' );
347 $container.find( '.charitable-paypal-pane' ).removeClass( 'active' );
348 $container.find( '.charitable-paypal-pane[data-method="' + method + '"]' ).addClass( 'active' );
349 },
350
351 /**
352 * Render PayPal buttons.
353 *
354 * @param {jQuery} $container Container element.
355 */
356 renderButtons: function( $container ) {
357 var self = this;
358
359 // Wrap SDK buttons in a stable container.
360 var $sdkWrapper = $( '<div class="charitable-paypal-sdk-buttons"></div>' );
361 $container.append( $sdkWrapper );
362
363 try {
364 this.buttonsInstance = paypal.Buttons( {
365 style: this.config.button_style,
366 appSwitchWhenAvailable: true,
367
368 /**
369 * Create order when button is clicked.
370 */
371 createOrder: function( data, actions ) {
372 return self.createOrder( data.fundingSource || 'paypal' );
373 },
374
375 /**
376 * Handle approval.
377 */
378 onApprove: function( data, actions ) {
379 return self.onApprove( data );
380 },
381
382 /**
383 * Handle cancellation.
384 */
385 onCancel: function( data ) {
386 self.onCancel( data );
387 },
388
389 /**
390 * Handle errors.
391 */
392 onError: function( err ) {
393 self.onError( err );
394 },
395
396 /**
397 * Called when button is clicked (before createOrder).
398 */
399 onClick: function( data, actions ) {
400 // Validate form before proceeding.
401 if ( ! self.validateForm() ) {
402 return actions.reject();
403 }
404 return actions.resolve();
405 }
406 } );
407
408 this.buttonsInstance.render( $sdkWrapper[0] ).then( function() {
409 self.buttonsRendered = true;
410 } );
411
412 } catch ( err ) {
413 console.error( 'Charitable PayPal Commerce: Error rendering buttons:', err );
414 // Build the notice via DOM nodes + .text() instead of concatenating into .html()
415 // so the i18n.error string is rendered as text regardless of its contents.
416 // Defense-in-depth: the source value is server-controlled via wp_localize_script,
417 // but using text() removes the possibility of a future contributor making the
418 // string user-influenced and breaking out of the surrounding tag.
419 $sdkWrapper.empty().append(
420 $( '<p>', { 'class': 'charitable-notice' } ).text( this.config.i18n.error )
421 );
422 }
423 },
424
425 /**
426 * Render Card Fields (ACDC).
427 */
428 renderCardFields: function() {
429 var self = this;
430
431 // Check if Card Fields component is available.
432 if ( typeof paypal.CardFields === 'undefined' ) {
433 console.warn( 'Charitable PayPal Commerce: Card Fields component not available.' );
434 return;
435 }
436
437 try {
438 // Get card fields style from config.
439 var style = this.config.card_fields.style || {};
440
441 this.cardFieldsInstance = paypal.CardFields( {
442 style: style,
443
444 /**
445 * Create order for card payment.
446 */
447 createOrder: function() {
448 return self.createOrder( 'card' );
449 },
450
451 /**
452 * Handle approval after 3DS (if required).
453 */
454 onApprove: function( data ) {
455 return self.onApprove( data );
456 },
457
458 /**
459 * Handle errors.
460 */
461 onError: function( err ) {
462 self.onCardError( err );
463 }
464 } );
465
466 // Check eligibility.
467 if ( this.cardFieldsInstance.isEligible() ) {
468 // Render individual card fields.
469 this.cardFieldsInstance.NumberField().render( '#charitable-card-number' );
470 this.cardFieldsInstance.ExpiryField().render( '#charitable-card-expiry' );
471 this.cardFieldsInstance.CVVField().render( '#charitable-card-cvv' );
472
473 this.cardFieldsRendered = true;
474 } else {
475 console.warn( 'Charitable PayPal Commerce: Card Fields not eligible for this merchant.' );
476 // Hide the card tab if not eligible.
477 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="card"]' ).hide();
478 }
479
480 } catch ( err ) {
481 console.error( 'Charitable PayPal Commerce: Error rendering card fields:', err );
482 // Hide the card tab on error.
483 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="card"]' ).hide();
484 }
485 },
486
487 /**
488 * Render Apple Pay button.
489 */
490 renderApplePay: function() {
491 var self = this;
492
493 // Check if Apple Pay component is available.
494 if ( typeof paypal.Applepay === 'undefined' ) {
495 console.warn( 'Charitable PayPal Commerce: Apple Pay component not available.' );
496 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="applepay"]' ).hide();
497 return;
498 }
499
500 // Check if Apple Pay is supported on this device.
501 if ( typeof window.ApplePaySession === 'undefined' || ! window.ApplePaySession.canMakePayments() ) {
502 console.warn( 'Charitable PayPal Commerce: Apple Pay not supported on this device.' );
503 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="applepay"]' ).hide();
504 return;
505 }
506
507 try {
508 this.applePayInstance = paypal.Applepay( {
509 /**
510 * Create order for Apple Pay payment.
511 */
512 createOrder: function() {
513 return self.createOrder( 'applepay' );
514 },
515
516 /**
517 * Handle approval.
518 */
519 onApprove: function( data ) {
520 return self.onApprove( data );
521 },
522
523 /**
524 * Handle errors.
525 */
526 onError: function( err ) {
527 self.onError( err );
528 },
529
530 /**
531 * Validate form before proceeding.
532 */
533 onClick: function( data, actions ) {
534 if ( ! self.validateForm() ) {
535 return actions.reject();
536 }
537 return actions.resolve();
538 }
539 } );
540
541 // Try different eligibility checking approaches
542 var isEligible = false;
543
544 if ( typeof this.applePayInstance.isEligible === 'function' ) {
545 // Standard approach
546 isEligible = this.applePayInstance.isEligible();
547 } else if ( typeof this.applePayInstance.canMakePayments === 'function' ) {
548 // Alternative approach
549 isEligible = this.applePayInstance.canMakePayments();
550 } else {
551 // Fallback - just try to render and see what happens
552 isEligible = true;
553 }
554
555 // Apple Pay doesn't have a render method - create the button manually
556 this.renderApplePayButton();
557 this.applePayRendered = true;
558
559 // Pre-fetch Apple Pay config so it's available synchronously on click.
560 // ApplePaySession must be created from a synchronous user gesture handler.
561 this.applePayInstance.config().then( function( cfg ) {
562 self._applePayConfig = cfg;
563 }).catch( function( err ) {
564 console.warn( 'Charitable PayPal Commerce: Could not pre-fetch Apple Pay config:', err );
565 });
566
567 } catch ( err ) {
568 console.error( 'Charitable PayPal Commerce: Error rendering Apple Pay:', err );
569 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="applepay"]' ).hide();
570 }
571 },
572
573 /**
574 * Render Google Pay button.
575 */
576 renderGooglePay: function() {
577 var self = this;
578
579 // Check if PayPal Google Pay component is available.
580 if ( typeof paypal.Googlepay === 'undefined' ) {
581 console.warn( 'Charitable PayPal Commerce: Google Pay component not available.' );
582 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="googlepay"]' ).hide();
583 return;
584 }
585
586 // Check if Google Pay API is loaded.
587 if ( typeof google === 'undefined' || typeof google.payments === 'undefined' ) {
588 console.warn( 'Charitable PayPal Commerce: Google Pay API not available.' );
589 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="googlepay"]' ).hide();
590 return;
591 }
592
593 try {
594 this.googlePayInstance = paypal.Googlepay();
595
596 var isSandbox = this.config.google_pay && this.config.google_pay.sandbox;
597 var $container = this.$form.find( '#charitable-google-pay-container' );
598
599 // Fetch Google Pay config from PayPal SDK.
600 this.googlePayInstance.config().then( function( googlePayConfig ) {
601
602 var paymentsClient = new google.payments.api.PaymentsClient({
603 environment: isSandbox ? 'TEST' : 'PRODUCTION'
604 });
605
606 return paymentsClient.isReadyToPay({
607 apiVersion: googlePayConfig.apiVersion,
608 apiVersionMinor: googlePayConfig.apiVersionMinor,
609 allowedPaymentMethods: googlePayConfig.allowedPaymentMethods
610 }).then( function( response ) {
611 if ( ! response.result ) {
612 console.warn( 'Charitable PayPal Commerce: Google Pay not ready to pay.' );
613 self.$form.find( '.charitable-paypal-payment-method-tab[data-method="googlepay"]' ).hide();
614 return;
615 }
616
617 // Render the official Google Pay button.
618 var button = paymentsClient.createButton({
619 onClick: function() {
620 if ( ! self.validateForm() ) {
621 return;
622 }
623
624 self.showProcessing();
625
626 var amount = String( self.getDonationAmount() );
627
628 self.createOrder( 'googlepay' ).then( function( orderID ) {
629 // Build merchantInfo: ensure merchantName is present (required by Google Pay).
630 // In TEST mode, merchantId must be omitted — passing a real ID causes OR_BIBED_06.
631 var merchantInfo = Object.assign( {}, googlePayConfig.merchantInfo );
632 if ( ! merchantInfo.merchantName && self.config.google_pay && self.config.google_pay.merchant_name ) {
633 merchantInfo.merchantName = self.config.google_pay.merchant_name;
634 }
635 if ( isSandbox && merchantInfo.merchantId ) {
636 delete merchantInfo.merchantId;
637 }
638
639 // Build the payment data request from PayPal's config.
640 // Explicitly pick known-valid fields to avoid leaking internal PayPal properties
641 // (e.g. isEligible) into the Google Pay API request.
642 var paymentDataRequest = {
643 apiVersion: googlePayConfig.apiVersion,
644 apiVersionMinor: googlePayConfig.apiVersionMinor,
645 allowedPaymentMethods: googlePayConfig.allowedPaymentMethods,
646 merchantInfo: merchantInfo,
647 transactionInfo: {
648 totalPriceStatus: 'FINAL',
649 totalPrice: amount,
650 currencyCode: self.config.currency || 'USD'
651 }
652 };
653 return paymentsClient.loadPaymentData( paymentDataRequest ).then( function( paymentData ) {
654 return self.googlePayInstance.confirmOrder({
655 orderId: orderID,
656 paymentMethodData: paymentData.paymentMethodData
657 }).then( function() {
658 return self.onApprove({ orderID: orderID });
659 });
660 });
661
662 }).catch( function( err ) {
663 self.hideProcessing();
664 if ( err && err.statusCode === 'CANCELED' ) {
665 return; // User cancelled — no error needed.
666 }
667 self.onError( err );
668 });
669 }
670 });
671
672 $container.empty().append( button );
673 self.googlePayRendered = true;
674 });
675
676 }).catch( function( err ) {
677 console.error( 'Charitable PayPal Commerce: Error initialising Google Pay:', err );
678 self.$form.find( '.charitable-paypal-payment-method-tab[data-method="googlepay"]' ).hide();
679 });
680
681 } catch ( err ) {
682 console.error( 'Charitable PayPal Commerce: Error rendering Google Pay:', err );
683 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="googlepay"]' ).hide();
684 }
685 },
686
687 /**
688 * Render Apple Pay button manually.
689 */
690 renderApplePayButton: function() {
691 var self = this;
692 var $container = this.$form.find( '#charitable-apple-pay-container' );
693
694 // Create Apple Pay button HTML
695 var buttonHtml = '<button type="button" id="charitable-apple-pay-button" class="charitable-apple-pay-button">' +
696 '<span class="apple-pay-logo"></span>' +
697 '<span class="apple-pay-text">Pay</span>' +
698 '</button>';
699
700 $container.html( buttonHtml );
701
702 // Add click handler
703 $container.find( '#charitable-apple-pay-button' ).on( 'click', function( e ) {
704 e.preventDefault();
705 self.handleApplePayClick();
706 } );
707 },
708
709 /**
710 * Handle Apple Pay button click.
711 */
712 handleApplePayClick: function() {
713 var self = this;
714
715 if ( ! this.validateForm() ) {
716 return;
717 }
718
719 if ( ! this.applePayInstance ) {
720 this.showError( 'Apple Pay not available' );
721 return;
722 }
723
724 if ( ! this._applePayConfig ) {
725 this.showError( 'Apple Pay is not ready yet. Please try again.' );
726 return;
727 }
728
729 // ApplePaySession MUST be created synchronously from a user gesture handler.
730 // Use the pre-fetched config; createOrder runs inside onvalidatemerchant.
731 var applePayConfig = this._applePayConfig;
732 var paymentRequest = {
733 countryCode: applePayConfig.countryCode,
734 merchantCapabilities: applePayConfig.merchantCapabilities,
735 supportedNetworks: applePayConfig.supportedNetworks,
736 currencyCode: self.config.currency || 'USD',
737 requiredBillingContactFields: [ 'postalAddress' ],
738 total: {
739 label: 'Donation',
740 type: 'final',
741 amount: String( self.getDonationAmount() )
742 }
743 };
744
745 var session = new ApplePaySession( 4, paymentRequest );
746 var orderID = null;
747
748 this.showProcessing();
749
750 session.onvalidatemerchant = function( event ) {
751 // Create the order and validate merchant concurrently.
752 Promise.all([
753 self.createOrder( 'applepay' ),
754 self.applePayInstance.validateMerchant({ validationUrl: event.validationURL })
755 ]).then( function( results ) {
756 orderID = results[0];
757 session.completeMerchantValidation( results[1].merchantSession );
758 }).catch( function( err ) {
759 console.error( 'Charitable PayPal Commerce: Apple Pay setup failed:', err );
760 session.abort();
761 self.hideProcessing();
762 self.showError( self.config.i18n.error );
763 });
764 };
765
766 session.onpaymentauthorized = function( event ) {
767 debugLog( 'Charitable PayPal Commerce: onpaymentauthorized fired. orderID:', orderID );
768 self.applePayInstance.confirmOrder({
769 orderId: orderID,
770 token: event.payment.token,
771 billingContact: event.payment.billingContact
772 }).then( function( confirmResult ) {
773 debugLog( 'Charitable PayPal Commerce: confirmOrder success:', confirmResult );
774 session.completePayment( ApplePaySession.STATUS_SUCCESS );
775 self.onApprove({ orderID: orderID });
776 }).catch( function( err ) {
777 console.error( 'Charitable PayPal Commerce: confirmOrder failed:', err );
778 session.completePayment( ApplePaySession.STATUS_FAILURE );
779 self.hideProcessing();
780 self.onError( err );
781 });
782 };
783
784 session.oncancel = function() {
785 self.hideProcessing();
786 };
787
788 self.hideProcessing();
789 session.begin();
790 },
791
792 /**
793 * Initialize Fastlane.
794 */
795 initFastlane: function() {
796 var self = this;
797
798 // Check if Fastlane should be shown.
799 if ( ! this.shouldShowFastlane() ) {
800 debugLog( 'Charitable PayPal Commerce: Fastlane not available for current user' );
801 return;
802 }
803
804 // Check if Fastlane component is available.
805 if ( typeof paypal.Fastlane === 'undefined' ) {
806 console.warn( 'Charitable PayPal Commerce: Fastlane component not available.' );
807 return;
808 }
809
810 // Get client token first.
811 this.getClientToken().then( function( clientToken ) {
812 if ( ! clientToken ) {
813 console.error( 'Charitable PayPal Commerce: Failed to get client token for Fastlane' );
814 return;
815 }
816
817 self.clientToken = clientToken;
818
819 // Initialize Fastlane.
820 return paypal.Fastlane( {
821 clientToken: clientToken
822 } );
823
824 } ).then( function( fastlaneInstance ) {
825 if ( ! fastlaneInstance ) {
826 console.warn( 'Charitable PayPal Commerce: Fastlane instance is null, skipping.' );
827 return;
828 }
829
830 self.fastlaneInstance = fastlaneInstance;
831 debugLog( 'Charitable PayPal Commerce: Fastlane initialized successfully' );
832
833 // Hide the PayPal tab to eliminate duplicate PayPal/Venmo/Pay Later buttons.
834 // The Fastlane SDK renders express PayPal/Venmo buttons in #charitable-fastlane-container,
835 // which already covers the PayPal wallet payment path.
836 var $paypalTab = self.$form.find( '.charitable-paypal-payment-method-tab[data-method="paypal"]' );
837 var $paypalPane = self.$form.find( '.charitable-paypal-pane[data-method="paypal"]' );
838 $paypalTab.hide().removeClass( 'active' );
839 $paypalPane.hide().removeClass( 'active' );
840
841 // Activate the next available tab (card preferred, then Google Pay, Apple Pay)
842 // using direct DOM manipulation to avoid triggering delegated events inside a Promise.
843 var $nextTab = self.$form.find(
844 '.charitable-paypal-payment-method-tab[data-method="card"],' +
845 '.charitable-paypal-payment-method-tab[data-method="googlepay"],' +
846 '.charitable-paypal-payment-method-tab[data-method="applepay"]'
847 ).first();
848
849 if ( $nextTab.length ) {
850 var nextMethod = $nextTab.data( 'method' );
851 self.$form.find( '.charitable-paypal-payment-method-tab' ).removeClass( 'active' );
852 $nextTab.addClass( 'active' );
853 self.$form.find( '.charitable-paypal-pane' ).removeClass( 'active' );
854 self.$form.find( '.charitable-paypal-pane[data-method="' + nextMethod + '"]' ).addClass( 'active' );
855 self.selectedPaymentMethod = nextMethod;
856 } else {
857 // No other tabs — hide the tabs bar entirely.
858 self.$form.find( '.charitable-paypal-payment-tabs' ).hide();
859 }
860
861 // Render Fastlane if we have an email.
862 self.renderFastlane();
863
864 } ).catch( function( err ) {
865 console.error( 'Charitable PayPal Commerce: Error initializing Fastlane:', err );
866
867 // Graceful degradation: Hide Fastlane tab and ensure other payment methods work.
868 self.$form.find( '.charitable-paypal-payment-method-tab[data-method="fastlane"]' ).hide();
869
870 // If Fastlane was the only payment method, ensure PayPal button is visible.
871 var $paypalTab = self.$form.find( '.charitable-paypal-payment-method-tab[data-method="paypal"]' );
872 if ( $paypalTab.length && ! $paypalTab.is( ':visible' ) ) {
873 $paypalTab.show();
874 self.onPaymentMethodTabClick( { currentTarget: $paypalTab[0], preventDefault: function() {} } );
875 }
876
877 debugLog( 'Charitable PayPal Commerce: Fastlane fallback to standard PayPal completed' );
878 } );
879 },
880
881 /**
882 * Check if Fastlane should be shown for current user.
883 */
884 shouldShowFastlane: function() {
885 // Check if Fastlane is enabled in config.
886 if ( ! this.config.fastlane || ! this.config.fastlane.enabled ) {
887 return false;
888 }
889
890 var behavior = this.config.fastlane.behavior || 'guest_only';
891 var isLoggedIn = this.config.is_logged_in || false;
892
893 // Show for all users if configured.
894 if ( 'all_users' === behavior ) {
895 return true;
896 }
897
898 // Default behavior: guest users only.
899 return ! isLoggedIn;
900 },
901
902 /**
903 * Get client token for Fastlane.
904 * Uses the pre-generated token from localized config when available so that
905 * it matches the data-user-id-token embedded on the SDK script tag.
906 * Falls back to AJAX if not pre-generated.
907 */
908 getClientToken: function() {
909 var preGenerated = CharitablePayPalCommerce.config.fastlane && CharitablePayPalCommerce.config.fastlane.client_token;
910 if ( preGenerated ) {
911 return Promise.resolve( preGenerated );
912 }
913 return new Promise( function( resolve, reject ) {
914 $.ajax( {
915 url: CharitablePayPalCommerce.config.ajax_url,
916 type: 'POST',
917 data: {
918 action: 'charitable_paypal_commerce_get_client_token',
919 nonce: CharitablePayPalCommerce.config.nonce
920 },
921 success: function( response ) {
922 if ( response.success && response.data.client_token ) {
923 resolve( response.data.client_token );
924 } else {
925 reject( new Error( response.data ? response.data.message : 'Failed to get client token' ) );
926 }
927 },
928 error: function( xhr, status, error ) {
929 reject( new Error( 'AJAX error: ' + error ) );
930 }
931 } );
932 } );
933 },
934
935 /**
936 * Render Fastlane.
937 */
938 renderFastlane: function() {
939 var self = this;
940
941 var $container = this.$form.find( '#charitable-fastlane-container' );
942
943 if ( $container.length === 0 ) {
944 console.warn( 'Charitable PayPal Commerce: Fastlane container not found' );
945 return;
946 }
947
948 // Build Fastlane UI with PayPal button above email (IWT items 1, 3, 4).
949 var fastlaneHtml =
950 '<div id="charitable-fastlane-paypal-button-container"></div>' +
951 '<div class="charitable-fastlane-or-divider"><span>' + ( this.config.i18n.or || 'or' ) + '</span></div>' +
952 '<div class="charitable-fastlane-wrapper">' +
953 '<div class="charitable-fastlane-email-section">' +
954 '<div class="charitable-fastlane-email-label-row">' +
955 '<label for="charitable-fastlane-email">' + this.config.i18n.email_address + '</label>' +
956 '<div id="charitable-fastlane-watermark"></div>' +
957 '</div>' +
958 '<input type="email" id="charitable-fastlane-email" name="fastlane_email" placeholder="' + this.config.i18n.email_placeholder + '">' +
959 '<button type="button" id="charitable-fastlane-lookup" class="charitable-fastlane-btn">' + this.config.i18n.continue + '</button>' +
960 '</div>' +
961 '<div class="charitable-fastlane-payment-section" style="display:none;">' +
962 '<div id="charitable-fastlane-payment-component"></div>' +
963 '<button type="button" id="charitable-fastlane-pay-button" class="charitable-fastlane-pay-btn" style="display:none;">' + this.config.i18n.complete_payment + '</button>' +
964 '</div>' +
965 '</div>';
966
967 $container.html( fastlaneHtml );
968
969 // Render PayPal button above email field (IWT items 3 & 4).
970 if ( typeof paypal !== 'undefined' && paypal.Buttons ) {
971 var fastlanePaypalButtons = paypal.Buttons( {
972 style: this.config.button_style,
973 createOrder: function() {
974 return self.createOrder( 'paypal' );
975 },
976 onApprove: function( data ) {
977 return self.onApprove( data );
978 },
979 onError: function( err ) {
980 self.onError( err );
981 },
982 onCancel: function() {
983 self.onCancel();
984 }
985 } );
986
987 if ( fastlanePaypalButtons.isEligible() ) {
988 fastlanePaypalButtons.render( '#charitable-fastlane-paypal-button-container' );
989 }
990 }
991
992 // Render Fastlane watermark component (IWT item 1).
993 if ( this.fastlaneInstance && typeof this.fastlaneInstance.FastlaneWatermarkComponent === 'function' ) {
994 this.fastlaneInstance.FastlaneWatermarkComponent( {
995 includeAdditionalInfo: true
996 } ).then( function( watermarkComponent ) {
997 watermarkComponent.render( '#charitable-fastlane-watermark' );
998 } ).catch( function( err ) {
999 console.warn( 'Charitable PayPal Commerce: Fastlane watermark render failed:', err );
1000 } );
1001 }
1002
1003 // Bind events.
1004 $container.find( '#charitable-fastlane-lookup' ).on( 'click', function() {
1005 self.handleFastlaneEmailLookup();
1006 } );
1007
1008 $container.find( '#charitable-fastlane-email' ).on( 'keypress', function( e ) {
1009 if ( e.which === 13 ) { // Enter key
1010 self.handleFastlaneEmailLookup();
1011 }
1012 } );
1013
1014 this.fastlaneRendered = true;
1015 debugLog( 'Charitable PayPal Commerce: Fastlane UI rendered' );
1016 },
1017
1018 /**
1019 * Handle Fastlane email lookup.
1020 */
1021 handleFastlaneEmailLookup: function() {
1022 var self = this;
1023 var email = this.$form.find( '#charitable-fastlane-email' ).val().trim();
1024
1025 if ( ! email || ! this.isValidEmail( email ) ) {
1026 this.showError( this.config.i18n.invalid_email );
1027 return;
1028 }
1029
1030 // Show loading state.
1031 var $button = this.$form.find( '#charitable-fastlane-lookup' );
1032 var originalText = $button.text();
1033 $button.text( this.config.i18n.loading ).prop( 'disabled', true );
1034
1035 // Use Fastlane identity lookup.
1036 if ( this.fastlaneInstance && typeof this.fastlaneInstance.identity === 'object' &&
1037 typeof this.fastlaneInstance.identity.lookupCustomerByEmail === 'function' ) {
1038
1039 this.fastlaneInstance.identity.lookupCustomerByEmail( email ).then( function( result ) {
1040 if ( result && result.customerContextId ) {
1041 // Customer found - trigger member authentication.
1042 debugLog( 'Charitable PayPal Commerce: Fastlane customer found, triggering authentication' );
1043 self.handleFastlaneMemberAuthentication( result.customerContextId );
1044 } else {
1045 // New customer - proceed with guest flow.
1046 debugLog( 'Charitable PayPal Commerce: New Fastlane customer, proceeding with guest flow' );
1047 self.handleFastlaneGuestFlow( email );
1048 }
1049 } ).catch( function( err ) {
1050 console.error( 'Charitable PayPal Commerce: Fastlane lookup error:', err );
1051 // Fallback to guest flow.
1052 self.handleFastlaneGuestFlow( email );
1053 } ).finally( function() {
1054 // Restore button.
1055 $button.text( originalText ).prop( 'disabled', false );
1056 } );
1057
1058 } else {
1059 // Fallback: proceed with guest flow.
1060 debugLog( 'Charitable PayPal Commerce: Fastlane identity lookup not available, using guest flow' );
1061 this.handleFastlaneGuestFlow( email );
1062 $button.text( originalText ).prop( 'disabled', false );
1063 }
1064 },
1065
1066 /**
1067 * Handle Fastlane member authentication.
1068 */
1069 handleFastlaneMemberAuthentication: function( customerContextId ) {
1070 var self = this;
1071
1072 if ( this.fastlaneInstance && typeof this.fastlaneInstance.identity === 'object' &&
1073 typeof this.fastlaneInstance.identity.triggerAuthenticationFlow === 'function' ) {
1074
1075 this.fastlaneInstance.identity.triggerAuthenticationFlow( customerContextId ).then( function( authResult ) {
1076 if ( authResult && authResult.authenticationState === 'succeeded' ) {
1077 // OTP completed — prefilled accelerated flow (IWT item 11).
1078 debugLog( 'Charitable PayPal Commerce: Fastlane authentication successful' );
1079 self.showFastlanePaymentSection( authResult.profileData, false );
1080 } else {
1081 // OTP cancelled/failed — card fields, no consent toggle (IWT item 12).
1082 debugLog( 'Charitable PayPal Commerce: Fastlane OTP cancelled — showing card fields without consent' );
1083 self.showFastlanePaymentSection( {}, true );
1084 }
1085 } ).catch( function( err ) {
1086 console.error( 'Charitable PayPal Commerce: Fastlane authentication error:', err );
1087 // Auth error — treat as cancelled OTP (no consent toggle).
1088 self.showFastlanePaymentSection( {}, true );
1089 } );
1090
1091 } else {
1092 console.warn( 'Charitable PayPal Commerce: Fastlane authentication not available' );
1093 self.showFastlanePaymentSection( {}, true );
1094 }
1095 },
1096
1097 /**
1098 * Handle Fastlane guest flow.
1099 */
1100 handleFastlaneGuestFlow: function( email ) {
1101 debugLog( 'Charitable PayPal Commerce: Starting Fastlane guest flow for:', email );
1102 // Show payment section for guest checkout.
1103 this.showFastlanePaymentSection( { email: email } );
1104 },
1105
1106 /**
1107 * Show Fastlane payment section.
1108 *
1109 * @param {Object} profileData Profile data from authenticated member (or empty object).
1110 * @param {boolean} isMemberOtpCancelled True when a Fastlane member cancelled OTP (no consent toggle).
1111 */
1112 showFastlanePaymentSection: function( profileData, isMemberOtpCancelled ) {
1113 var self = this;
1114
1115 // Hide email section, show payment section.
1116 this.$form.find( '.charitable-fastlane-email-section' ).hide();
1117 this.$form.find( '.charitable-fastlane-payment-section' ).show();
1118
1119 var $payButton = this.$form.find( '#charitable-fastlane-pay-button' );
1120
1121 // Render official FastlanePaymentComponent (IWT items 2, 5–12).
1122 if ( this.fastlaneInstance && typeof this.fastlaneInstance.FastlanePaymentComponent === 'function' ) {
1123
1124 this.fastlaneInstance.FastlanePaymentComponent().then( function( paymentComponent ) {
1125 self.fastlanePaymentComponent = paymentComponent;
1126 return paymentComponent.render( '#charitable-fastlane-payment-component' );
1127
1128 } ).then( function() {
1129 // Show submit button once component is rendered.
1130 $payButton.show();
1131
1132 $payButton.off( 'click' ).on( 'click', function() {
1133 self.fallbackToStandardPayPal();
1134 } );
1135
1136 debugLog( 'Charitable PayPal Commerce: Fastlane payment component rendered' );
1137
1138 } ).catch( function( err ) {
1139 console.error( 'Charitable PayPal Commerce: Fastlane payment component error:', err );
1140 self.fallbackToStandardPayPal();
1141 } );
1142
1143 } else {
1144 // SDK component not available — fall back to standard checkout.
1145 console.warn( 'Charitable PayPal Commerce: FastlanePaymentComponent not available, falling back' );
1146 self.fallbackToStandardPayPal();
1147 }
1148 },
1149
1150 /**
1151 * Create a PayPal order using a Fastlane single-use payment token.
1152 *
1153 * @param {string} fastlaneToken Token from FastlanePaymentComponent.getPaymentToken().
1154 * @return {Promise<string>} Resolves with the PayPal order ID.
1155 */
1156 createFastlaneOrder: function( fastlaneToken ) {
1157 var self = this;
1158 var $form = this.$form;
1159
1160 return new Promise( function( resolve, reject ) {
1161 var amount = self.getDonationAmount();
1162 var campaignId = $form.find( 'input[name="campaign_id"]' ).val() || 0;
1163
1164 $.ajax( {
1165 type: 'POST',
1166 url: self.config.ajax_url,
1167 data: {
1168 action: 'charitable_paypal_commerce_create_order',
1169 amount: amount,
1170 campaign_id: campaignId,
1171 currency: self.config.currency,
1172 form_data: $form.serialize(),
1173 payment_method: 'fastlane',
1174 fastlane_token: fastlaneToken,
1175 nonce: self.config.nonce
1176 },
1177 dataType: 'json',
1178 success: function( response ) {
1179 if ( response.success && response.data && response.data.order_id ) {
1180 resolve( response.data.order_id );
1181 } else {
1182 var msg = ( response.data && response.data.message ) ? response.data.message : self.config.i18n.error;
1183 reject( new Error( msg ) );
1184 }
1185 },
1186 error: function( xhr, status, error ) {
1187 reject( new Error( error ) );
1188 }
1189 } );
1190 } );
1191 },
1192
1193 /**
1194 * Validate email address.
1195 */
1196 isValidEmail: function( email ) {
1197 var regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1198 return regex.test( email );
1199 },
1200
1201 /**
1202 * Submit card payment.
1203 */
1204 submitCardPayment: function() {
1205 var self = this;
1206
1207 // Validate form first.
1208 if ( ! this.validateForm() ) {
1209 return;
1210 }
1211
1212 // Validate card name.
1213 var cardName = this.$form.find( '#charitable-card-name' ).val();
1214 if ( ! cardName || cardName.trim() === '' ) {
1215 this.showCardError( this.config.i18n.required_fields || 'Please fill in all required fields.' );
1216 return;
1217 }
1218
1219 if ( ! this.cardFieldsInstance ) {
1220 this.showCardError( this.config.i18n.error );
1221 return;
1222 }
1223
1224 this.showProcessing();
1225
1226 // Get billing address from form if available.
1227 var billingAddress = this.getBillingAddress();
1228
1229 // Submit card fields.
1230 this.cardFieldsInstance.submit( {
1231 cardholderName: cardName,
1232 billingAddress: billingAddress
1233 } ).then( function( data ) {
1234 // Card payment submitted - approval handled by onApprove callback.
1235 } ).catch( function( err ) {
1236 self.hideProcessing();
1237 self.onCardError( err );
1238 } );
1239 },
1240
1241 /**
1242 * Get billing address from form.
1243 *
1244 * @return {Object} Billing address object.
1245 */
1246 getBillingAddress: function() {
1247 var $form = this.$form;
1248
1249 return {
1250 addressLine1: $form.find( 'input[name="address"]' ).val() || '',
1251 addressLine2: $form.find( 'input[name="address_2"]' ).val() || '',
1252 adminArea2: $form.find( 'input[name="city"]' ).val() || '', // City.
1253 adminArea1: $form.find( 'input[name="state"], select[name="state"]' ).val() || '', // State.
1254 postalCode: $form.find( 'input[name="postcode"]' ).val() || '',
1255 countryCode: $form.find( 'input[name="country"], select[name="country"]' ).val() || 'US'
1256 };
1257 },
1258
1259 /**
1260 * Handle card-specific errors.
1261 *
1262 * @param {Error|Object} err Error object.
1263 */
1264 onCardError: function( err ) {
1265 this.hideProcessing();
1266
1267 var message = this.config.i18n.card_error || 'Please check your card details and try again.';
1268
1269 // Try to extract more specific error message.
1270 if ( err && err.message ) {
1271 message = err.message;
1272 } else if ( err && err.details && err.details.length > 0 ) {
1273 message = err.details[0].description || message;
1274 }
1275
1276 console.error( 'Charitable PayPal Commerce: Card error:', err );
1277 this.showCardError( message );
1278 },
1279
1280 /**
1281 * Show card-specific error.
1282 *
1283 * @param {string} message Error message.
1284 */
1285 showCardError: function( message ) {
1286 var $errors = this.$form.find( '.charitable-paypal-card-errors' );
1287 /* Build via .text() — `message` ultimately comes from the PayPal
1288 * SDK error object (err.message / err.details[].description),
1289 * which is loaded from a CDN and not contractually sanitized.
1290 * Using .html() with string concatenation here would be an XSS
1291 * vector if PayPal's SDK ever surfaces attacker-controlled
1292 * fields. */
1293 var $p = $( '<p class="charitable-notice charitable-notice-error"></p>' );
1294 $p.text( message );
1295 $errors.empty().append( $p ).show();
1296
1297 // Also use Charitable's notice system if available.
1298 if ( typeof CHARITABLE !== 'undefined' && CHARITABLE.notices ) {
1299 CHARITABLE.notices.add_error( message );
1300 }
1301 },
1302
1303 /**
1304 * Clear card errors.
1305 */
1306 clearCardErrors: function() {
1307 var $errors = this.$form.find( '.charitable-paypal-card-errors' );
1308 $errors.empty().hide();
1309 },
1310
1311 /**
1312 * Validate the donation form.
1313 *
1314 * @return {boolean}
1315 */
1316 validateForm: function() {
1317 var $form = this.$form;
1318
1319 // Clear previous card errors.
1320 this.clearCardErrors();
1321
1322 // Check for amount.
1323 var amount = this.getDonationAmount();
1324 if ( ! amount || amount <= 0 ) {
1325 this.showError( this.config.i18n.select_amount );
1326 return false;
1327 }
1328
1329 // Basic required field validation for PayPal Commerce.
1330 // We don't trigger charitable:form:validate as it invokes credit card
1331 // validation which is not needed for PayPal payments.
1332 var isValid = true;
1333
1334 // Check required donor fields. Checkboxes and radios have their
1335 // "value" attribute set (typically "1") regardless of checked
1336 // state, so testing .val() lets unchecked required checkboxes
1337 // (e.g. accept_terms) pass validation. Inspect :checked instead
1338 // for those input types.
1339 $form.find( '[required]' ).each( function() {
1340 var $field = $( this );
1341 var type = ( $field.attr( 'type' ) || '' ).toLowerCase();
1342 var hasValue;
1343
1344 if ( type === 'checkbox' || type === 'radio' ) {
1345 hasValue = $field.is( ':checked' );
1346 } else {
1347 hasValue = $field.val() && $field.val().toString().trim() !== '';
1348 }
1349
1350 if ( ! hasValue ) {
1351 isValid = false;
1352 $field.addClass( 'charitable-field-error' );
1353 } else {
1354 $field.removeClass( 'charitable-field-error' );
1355 }
1356 } );
1357
1358 if ( ! isValid ) {
1359 this.showError( this.config.i18n.required_fields || 'Please fill in all required fields.' );
1360 }
1361
1362 return isValid;
1363 },
1364
1365 /**
1366 * Get the donation amount from the form.
1367 *
1368 * @return {number}
1369 */
1370 getDonationAmount: function() {
1371 var $form = this.$form;
1372 var amount = 0;
1373
1374 var $selectedAmount = $form.find( 'input[name="donation_amount"]:checked' );
1375 var selectedVal = $selectedAmount.length ? $selectedAmount.val() : '';
1376
1377 if ( selectedVal !== '' && selectedVal !== 'custom' ) {
1378 // Suggested amount — trust the radio's numeric value and stop. Reading
1379 // custom_donation_amount past this point would let a stale custom field
1380 // (remembered session, template default, currency-conversion artifact)
1381 // override the donor's actual selection.
1382 amount = parseFloat( selectedVal );
1383 return isNaN( amount ) ? 0 : amount;
1384 }
1385
1386 // Either no radio is checked or the donor explicitly picked "custom".
1387 // Prefer the visible custom-donation-input over any sibling hidden mirror
1388 // since the visible field is what the donor (and the currency-conversion
1389 // addons) actually write to. Matches Charitable core's get_custom_amount
1390 // selector convention in charitable.js.
1391 var $customAmount = $form.find( 'input.custom-donation-input[name="custom_donation_amount"]' );
1392 if ( ! $customAmount.length ) {
1393 $customAmount = $form.find( 'input[name="custom_donation_amount"]' );
1394 }
1395 if ( $customAmount.length && $customAmount.val() ) {
1396 var customVal = parseFloat( $customAmount.val().toString().replace( /[^0-9.]/g, '' ) );
1397 if ( ! isNaN( customVal ) && customVal > 0 ) {
1398 amount = customVal;
1399 }
1400 }
1401
1402 return isNaN( amount ) ? 0 : amount;
1403 },
1404
1405 /**
1406 * Create a PayPal order.
1407 *
1408 * @param {string} paymentMethod Payment method ('paypal' or 'card').
1409 * @return {Promise}
1410 */
1411 createOrder: function( paymentMethod ) {
1412 var self = this;
1413 var $form = this.$form;
1414
1415 this.showProcessing();
1416
1417 // Create PayPal order directly with form data.
1418 // The Charitable donation will be created when the order is captured.
1419 return new Promise( function( resolve, reject ) {
1420 var amount = self.getDonationAmount();
1421 var campaignId = $form.find( 'input[name="campaign_id"]' ).val() || 0;
1422
1423 $.ajax( {
1424 type: 'POST',
1425 url: self.config.ajax_url,
1426 data: {
1427 action: 'charitable_paypal_commerce_create_order',
1428 amount: amount,
1429 campaign_id: campaignId,
1430 currency: self.config.currency,
1431 form_data: $form.serialize(),
1432 payment_method: paymentMethod || 'paypal',
1433 nonce: self.config.nonce
1434 },
1435 dataType: 'json',
1436 success: function( response ) {
1437 if ( response.success && response.data ) {
1438 // Handle order creation - return order ID to PayPal SDK.
1439 if ( response.data.order_id ) {
1440 // Hide overlay before handing back to SDK. PayPal's SDK
1441 // renders its own UI next (popup for wallet, inline iframe
1442 // for card fields); leaving the overlay up blocks inline
1443 // card-field input. onApprove re-shows it for capture.
1444 self.hideProcessing();
1445 resolve( response.data.order_id );
1446 } else {
1447 self.hideProcessing();
1448 var data = response.data || {};
1449 var message = data.message || self.config.i18n.error;
1450 self.showError( message, { detail: data.detail, debug_id: data.debug_id } );
1451 reject( new Error( message ) );
1452 }
1453 } else {
1454 self.hideProcessing();
1455 var data = response.data || {};
1456 var message = data.message || self.config.i18n.error;
1457 self.showError( message, { detail: data.detail, debug_id: data.debug_id } );
1458 reject( new Error( message ) );
1459 }
1460 },
1461 error: function( xhr, status, error ) {
1462 self.hideProcessing();
1463 self.showError( self.config.i18n.error );
1464 reject( new Error( error ) );
1465 }
1466 } );
1467 } );
1468 },
1469
1470 /**
1471 * Handle PayPal approval.
1472 *
1473 * @param {Object} data PayPal approval data.
1474 * @return {Promise}
1475 */
1476 onApprove: function( data ) {
1477 var self = this;
1478
1479 this.showProcessing();
1480
1481 return new Promise( function( resolve, reject ) {
1482 var ajaxData = {
1483 action: 'charitable_paypal_commerce_capture_order',
1484 order_id: data.orderID,
1485 nonce: self.config.nonce
1486 };
1487
1488 $.ajax( {
1489 type: 'POST',
1490 url: self.config.ajax_url,
1491 data: ajaxData,
1492 dataType: 'json',
1493 success: function( response ) {
1494 self.hideProcessing();
1495
1496 if ( response.success && response.data ) {
1497 if ( response.data.redirect ) {
1498 window.location.href = response.data.redirect;
1499 } else {
1500 self.showSuccess();
1501 }
1502 resolve();
1503 } else {
1504 var data = response.data || {};
1505 var message = data.message || self.config.i18n.error;
1506 self.showError( message, { detail: data.detail, debug_id: data.debug_id } );
1507 reject( new Error( message ) );
1508 }
1509 },
1510 error: function( xhr, status, error ) {
1511 self.hideProcessing();
1512 self.showError( self.config.i18n.error );
1513 reject( new Error( error ) );
1514 }
1515 } );
1516 } );
1517 },
1518
1519 /**
1520 * Handle PayPal cancellation.
1521 *
1522 * @param {Object} data Cancellation data.
1523 */
1524 onCancel: function( data ) {
1525 this.hideProcessing();
1526 // User cancelled - no need to show error.
1527 },
1528
1529 /**
1530 * Handle PayPal errors.
1531 *
1532 * @param {Error} err Error object.
1533 */
1534 onError: function( err ) {
1535 this.hideProcessing();
1536 console.error( 'Charitable PayPal Commerce: Error:', err );
1537
1538 /* The PayPal SDK fires onError after our createOrder Promise
1539 * rejects. createOrder may have already shown a specific error
1540 * from PayPal's response (e.g. "The merchant account is
1541 * restricted."). Don't clobber it with the generic
1542 * "There was an error processing your donation." Suppress this
1543 * showError if a more specific one was surfaced in the last 3s. */
1544 if ( this._lastErrorAt && ( Date.now() - this._lastErrorAt ) < 3000 ) {
1545 return;
1546 }
1547
1548 this.showError( this.config.i18n.error );
1549 },
1550
1551 /**
1552 * Handle gateway change.
1553 *
1554 * @param {Event} e Change event.
1555 */
1556 onGatewayChange: function( e ) {
1557 var $form = $( e.target ).closest( '.charitable-donation-form' );
1558 var $container = $form.find( '#charitable-paypal-commerce-container' );
1559 var selectedGateway = $( e.target ).val();
1560
1561 this.$form = $form;
1562
1563 if ( selectedGateway === this.config.gateway_id ) {
1564 $container.show();
1565 this.toggleSubmitButton( false );
1566
1567 if ( ! this.buttonsRendered ) {
1568 this.buildPaymentUI( $container );
1569 }
1570 } else {
1571 $container.hide();
1572 this.toggleSubmitButton( true );
1573 }
1574 },
1575
1576 /**
1577 * Handle form updated event.
1578 *
1579 * @param {Event} e Event object.
1580 * @param {jQuery} $form Form element.
1581 */
1582 onFormUpdated: function( e, $form ) {
1583 var $container = $form.find( '#charitable-paypal-commerce-container' );
1584
1585 if ( $container.length && ! $container.data( 'initialized' ) ) {
1586 this.initForm( $form, $container );
1587 }
1588 },
1589
1590 /**
1591 * Toggle the submit button visibility.
1592 *
1593 * @param {boolean} show Whether to show the button.
1594 */
1595 toggleSubmitButton: function( show ) {
1596 var $submit = this.$form.find( 'button[type="submit"], input[type="submit"]' );
1597
1598 if ( show ) {
1599 $submit.show();
1600 } else {
1601 $submit.hide();
1602 }
1603 },
1604
1605 /**
1606 * Show processing state.
1607 */
1608 showProcessing: function() {
1609 var $container = this.$form.find( '#charitable-paypal-commerce-container' );
1610 $container.addClass( 'processing' );
1611
1612 // Add processing overlay if not exists.
1613 if ( ! $container.find( '.charitable-paypal-processing' ).length ) {
1614 $container.append( '<div class="charitable-paypal-processing"><span>' + this.config.i18n.processing + '</span></div>' );
1615 }
1616
1617 // Disable card submit button.
1618 this.$form.find( '#charitable-card-submit' ).prop( 'disabled', true ).addClass( 'processing' );
1619 },
1620
1621 /**
1622 * Hide processing state.
1623 */
1624 hideProcessing: function() {
1625 var $container = this.$form.find( '#charitable-paypal-commerce-container' );
1626 $container.removeClass( 'processing' );
1627 $container.find( '.charitable-paypal-processing' ).remove();
1628
1629 // Re-enable card submit button.
1630 this.$form.find( '#charitable-card-submit' ).prop( 'disabled', false ).removeClass( 'processing' );
1631 },
1632
1633 /**
1634 * Show error message.
1635 *
1636 * Tier 1 — Charitable's site-wide notice system if it's exposed.
1637 * Tier 2 — inline error rendered directly inside the donation form.
1638 * Tier 3 — native alert(), only when no DOM target is available
1639 * (very unlikely path — defensive last resort).
1640 *
1641 * Records the time of the last error so onError() can suppress its
1642 * own generic message if a more specific one was just surfaced.
1643 *
1644 * @param {string} message Error message.
1645 */
1646 showError: function( message, options ) {
1647 options = options || {};
1648 this._lastErrorMessage = message;
1649 this._lastErrorAt = Date.now();
1650
1651 var primaryHandled = false;
1652
1653 // Tier 1 — Charitable's site-wide notice system if it's exposed.
1654 if ( typeof CHARITABLE !== 'undefined' && CHARITABLE.notices && typeof CHARITABLE.notices.add_error === 'function' ) {
1655 CHARITABLE.notices.add_error( message );
1656 primaryHandled = true;
1657 }
1658
1659 /* Tier 2 — inline error rendered directly inside the donation
1660 * form. Always runs when there's a DOM target so we can append
1661 * the admin-only detail block; if Tier 1 already showed the
1662 * primary message, this Tier 2 path skips the main heading and
1663 * only renders the detail (so admins see both the main notice
1664 * via Charitable's chrome AND the technical detail inline). */
1665 if ( this.$form && this.$form.length ) {
1666 var $container = this.$form.find( '#charitable-paypal-commerce-container' );
1667 if ( $container.length ) {
1668 $container.find( '.charitable-paypal-error' ).remove();
1669
1670 if ( ! primaryHandled ) {
1671 var $err = $(
1672 '<div class="charitable-notice charitable-notice-error charitable-paypal-error" role="alert" ' +
1673 'style="margin:1em 0;padding:0.75em 1em;border-left:4px solid #d63638;background:#fcf0f1;color:#1d2327;">' +
1674 '</div>'
1675 );
1676 $err.text( message );
1677
1678 /* Admin-only technical detail. Donors don't see this;
1679 * it gives signed-in admins enough to triage without
1680 * checking the logs. */
1681 if ( options.detail ) {
1682 var $detail = $(
1683 '<small class="charitable-paypal-error-detail" ' +
1684 'style="display:block;margin-top:0.5em;color:#646970;font-size:0.85em;"></small>'
1685 );
1686 var detailText = options.detail;
1687 if ( options.debug_id ) {
1688 detailText += ' (PayPal debug ID: ' + options.debug_id + ')';
1689 }
1690 $detail.text( detailText );
1691 $err.append( $detail );
1692 }
1693
1694 $container.prepend( $err );
1695
1696 if ( $err[0] && typeof $err[0].scrollIntoView === 'function' ) {
1697 $err[0].scrollIntoView( { behavior: 'smooth', block: 'center' } );
1698 }
1699
1700 setTimeout( function() {
1701 $err.fadeOut( 300, function() { $( this ).remove(); } );
1702 }, 10000 );
1703 } else if ( options.detail ) {
1704 /* Tier 1 owns the main message. Render only the
1705 * admin detail block here so signed-in admins still
1706 * get the raw PayPal text + debug_id without
1707 * duplicating the donor-facing copy. */
1708 var $detailOnly = $(
1709 '<small class="charitable-paypal-error-detail charitable-paypal-error" ' +
1710 'style="display:block;margin:0.5em 0 1em;padding:0.5em 0.75em;color:#646970;font-size:0.85em;border-left:3px solid #c3c4c7;background:#f6f7f7;"></small>'
1711 );
1712 var detailTextOnly = options.detail;
1713 if ( options.debug_id ) {
1714 detailTextOnly += ' (PayPal debug ID: ' + options.debug_id + ')';
1715 }
1716 $detailOnly.text( detailTextOnly );
1717 $container.prepend( $detailOnly );
1718 setTimeout( function() {
1719 $detailOnly.fadeOut( 300, function() { $( this ).remove(); } );
1720 }, 10000 );
1721 }
1722
1723 return;
1724 }
1725 }
1726
1727 if ( primaryHandled ) {
1728 return;
1729 }
1730
1731 /* Tier 3 — native alert. Donors should essentially never reach
1732 * this path; it exists so a misconfigured page (e.g. a
1733 * hand-built form embed missing the container) still surfaces
1734 * the error rather than swallowing it. */
1735 window.alert( message );
1736 },
1737
1738 /**
1739 * Show success state.
1740 */
1741 showSuccess: function() {
1742 var $container = this.$form.find( '#charitable-paypal-commerce-container' );
1743 $container.html( '<p class="charitable-notice charitable-notice-success">Donation successful!</p>' );
1744 },
1745
1746 /**
1747 * Check if error should trigger fallback from Fastlane.
1748 *
1749 * @param {Object} err Error object.
1750 * @return {boolean} True if should fallback.
1751 */
1752 shouldFallbackFromFastlane: function( err ) {
1753 // Check for specific Fastlane errors that indicate fallback is appropriate.
1754 var errorMessage = err.message || err.toString() || '';
1755 var errorLower = errorMessage.toLowerCase();
1756
1757 // List of error conditions that should trigger fallback.
1758 var fallbackTriggers = [
1759 'fastlane',
1760 'initialization',
1761 'not available',
1762 'component not found',
1763 'client token',
1764 'authentication failed'
1765 ];
1766
1767 // If error mentions any fallback triggers, we should fallback.
1768 for ( var i = 0; i < fallbackTriggers.length; i++ ) {
1769 if ( errorLower.indexOf( fallbackTriggers[i] ) !== -1 ) {
1770 return true;
1771 }
1772 }
1773
1774 // For any unrecognized error during Fastlane flow, fallback to be safe.
1775 return true;
1776 },
1777
1778 /**
1779 * Fallback to standard PayPal checkout.
1780 */
1781 fallbackToStandardPayPal: function() {
1782 var self = this;
1783
1784 debugLog( 'Charitable PayPal Commerce: Initiating fallback to standard PayPal' );
1785
1786 // Hide Fastlane components.
1787 this.$form.find( '.charitable-paypal-payment-method-tab[data-method="fastlane"]' ).hide();
1788 this.$form.find( '#charitable-fastlane-container' ).hide();
1789
1790 // Show and activate the PayPal tab.
1791 var $paypalTab = this.$form.find( '.charitable-paypal-payment-method-tab[data-method="paypal"]' );
1792 var $paypalContainer = this.$form.find( '#charitable-paypal-buttons-container' );
1793
1794 if ( $paypalTab.length ) {
1795 $paypalTab.show().addClass( 'active' );
1796
1797 // Remove active class from other tabs.
1798 this.$form.find( '.charitable-paypal-payment-method-tab' ).not( $paypalTab ).removeClass( 'active' );
1799
1800 // Show PayPal container and hide others.
1801 $paypalContainer.show();
1802 this.$form.find( '.charitable-paypal-payment-pane' ).not( $paypalContainer ).hide();
1803
1804 // Set selected method.
1805 this.selectedPaymentMethod = 'paypal';
1806
1807 // Show a user-friendly message about the fallback.
1808 this.showError( 'We\'ve switched you to our standard PayPal checkout for the best experience.' );
1809
1810 debugLog( 'Charitable PayPal Commerce: Fallback to standard PayPal completed' );
1811 } else {
1812 // Last resort - show general error.
1813 this.onError( new Error( 'Payment method not available. Please refresh the page and try again.' ) );
1814 }
1815 },
1816 };
1817
1818 // Initialize when DOM is ready.
1819 $( document ).ready( function() {
1820 CharitablePayPalCommerce.init();
1821 } );
1822
1823 } )( jQuery );
1824