| 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 |
|