1jQuery(($) => { 2 3 'use strict'; 4 5 /** 6 * Wallets handler. 7 * 8 * Interacts with the Poynt Collect API to process payments using Apple and Google Pay. 9 */ 10 window.MWCPaymentsWalletsHandler = class MWCPaymentsWalletsHandler { 11 12 /** @var string flag to display button on cart page */ 13 BUTTON_PAGE_CART = 'CART'; 14 15 /** @var string flag to display button on checkout page */ 16 BUTTON_PAGE_CHECKOUT = 'CHECKOUT'; 17 18 /** @var string flag to display button on single product pages */ 19 BUTTON_PAGE_SINGLE_PRODUCT = 'SINGLE_PRODUCT'; 20 21 /** 22 * Instantiates the Wallet on the current page. 23 * 24 * @param {Object} args form handler arguments 25 */ 26 constructor( args ) { 27 28 this.appId = args.appId; 29 this.businessId = args.businessId; 30 this.isLoggingEnabled = args.isLoggingEnabled; 31 this.apiUrl = args.apiUrl; 32 this.apiNonce = args.apiNonce; 33 this.initialized = false; 34 this.enabledButtons = args.enabledButtons; 35 36 // bail if no Wallet wrappers or enabled buttons exist on the page 37 if (! $('#mwc-payments-wallet-buttons').length || ! Object.keys(this.enabledButtons).length) { 38 return; 39 } 40 41 this.setup() 42 } 43 44 /** 45 * Sets up the Wallet context and page handlers 46 */ 47 setup() { 48 if ($('form.cart').length) { 49 this.context = this.BUTTON_PAGE_SINGLE_PRODUCT 50 this.uiElement = $('form.cart'); 51 this.handleProductPage(); 52 } else if ($('form.woocommerce-cart-form').length) { 53 this.context = this.BUTTON_PAGE_CART 54 this.uiElement = $('form.woocommerce-cart-form').parents('div.woocommerce'); 55 this.handleCartPage(); 56 } else if ($('form.woocommerce-checkout').length) { 57 this.context = this.BUTTON_PAGE_CHECKOUT 58 this.uiElement = $('form.woocommerce-checkout'); 59 this.handleCheckoutPage(); 60 } else { 61 this.debugLog('No payment form available'); 62 } 63 } 64 65 /** 66 * Determines whether we're currently on a single product page. 67 * 68 * @return boolean 69 */ 70 isProductPage() { 71 return this.context === this.BUTTON_PAGE_SINGLE_PRODUCT 72 } 73 74 /** 75 * Determines whether we're currently on the cart page. 76 * 77 * @return boolean 78 */ 79 isCartPage() { 80 return this.context === this.BUTTON_PAGE_CART 81 } 82 83 /** 84 * Determines whether we're currently on the checkout page. 85 * 86 * @return boolean 87 */ 88 isCheckoutPage() { 89 return this.context === this.BUTTON_PAGE_CHECKOUT 90 } 91 92 /** 93 * Handles setting up Wallet on the single product pages. 94 */ 95 handleProductPage() { 96 this.debugLog('Initializing the product page'); 97 98 // the initial payment request update is a dummy promise that resolves instantly 99 this.pendingPaymentRequestUpdate = new Promise((resolve => resolve())) 100 101 if (this.uiElement.hasClass('variations_form')) { 102 this.handleVariableProductPage(); 103 } else { 104 let products = this.getCurrentPageProducts() 105 106 if (products.length) { 107 this.initialize(products) 108 } 109 110 this.listenToProductQuantityChanges(); 111 } 112 } 113 114 /** 115 * Listens and handles changes to product quantities 116 */ 117 listenToProductQuantityChanges() { 118 this.uiElement.on('focus.mwc_payments_poynt_wallets', '[name^="quantity"]', (event) => { 119 $(event.target).data('previous-quantity', $(event.target).val()); 120 }) 121 122 this.uiElement.on('change.mwc_payments_poynt_wallets', '[name^="quantity"]', (event) => { 123 124 let previousQuantity = $(event.target).data('previous-quantity'); 125 let newQuantity = $(event.target).val(); 126 127 // only refresh the wallet request if the new quantity does not match the previous quantity 128 if (newQuantity === previousQuantity) { 129 return; 130 } 131 132 $(event.target).data('previous-quantity', newQuantity); 133 134 let products = this.getCurrentPageProducts() 135 136 if (products.length) { 137 this.showUI() 138 this.pendingWalletRequestUpdate = this.getPaymentRequest(products) 139 } else { 140 this.hideUI() 141 } 142 }) 143 144 } 145 146 /** 147 * Stops listening to changes to product quantities 148 */ 149 stopListeningToProductQuantityChanges() { 150 this.uiElement.off('change.mwc_payments_poynt_wallets', '[name^="quantity"]'); 151 this.uiElement.off('focus.mwc_payments_poynt_wallets', '[name^="quantity"]') 152 } 153 154 /** 155 * Handles setting up Wallet on the single product page for variable products. 156 */ 157 handleVariableProductPage() { 158 this.uiElement.on('show_variation', (event, variation, purchasable) => { 159 if (purchasable) { 160 this.reInitialize(this.getCurrentPageProducts()); 161 this.listenToProductQuantityChanges() 162 } 163 }); 164 this.uiElement.on('hide_variation', () => { 165 this.tearDown(); 166 this.stopListeningToProductQuantityChanges() 167 }); 168 } 169 170 /** 171 * Handles wallet button click on single product page. 172 * 173 * Adds the current product(s) to the cart on single product pages. 174 * 175 * @param {Object} event 176 */ 177 handleProductPageWalletButtonClick(event) { 178 179 this.blockUI() 180 181 // Start the wallet session as soon as the pending payment request update resolves. If it's already resolved, 182 // the payment sheet will open immediately. 183 this.pendingPaymentRequestUpdate.then((paymentRequest = {}) => { 184 185 this.startWalletSession(event.source, paymentRequest) 186 187 this.updateCart({ 188 products: this.getCurrentPageProducts(), 189 }).then(() => { 190 this.debugLog('Cart updated') 191 192 // The payment request should already be up-to-date by this point, so we don't need to update it here. 193 194 }).catch(err => { 195 this.debugLog('Failed to update cart', err) 196 this.handleApiError(err, event) 197 }); 198 }).finally(() => { 199 this.unblockUI() 200 }) 201 202 } 203 204 /** 205 * Gets a list of products with quantities on the current page, ready to be added to cart. 206 */ 207 getCurrentPageProducts() { 208 let products = []; 209 210 let addProduct = (productId, quantity) => { 211 212 if (! productId || isNaN(quantity) || quantity <= 0) { 213 return; 214 } 215 216 products.push({ 217 id: productId, 218 quantity: quantity, 219 }); 220 } 221 222 // handle grouped quantity inputs 223 if (this.uiElement.hasClass('grouped_form')) { 224 225 this.uiElement.find('input[name^="quantity"]').each((event, element) => { 226 addProduct( 227 parseInt($(element).attr('name').match(/[0-9]+/)), 228 parseFloat($(element).val()) 229 ) 230 }); 231 232 // handle simple & variable products 233 } else { 234 235 addProduct( 236 parseInt(this.uiElement.find('input[name="variation_id"]').val() || this.uiElement.find('button[name="add-to-cart"]').val()), 237 parseFloat(this.uiElement.find('input[name="quantity"]').val()) 238 ) 239 } 240 241 return products 242 } 243 244 /** 245 * Handles setting up Wallet on the cart page. 246 */ 247 handleCartPage() { 248 this.debugLog('Initializing the cart page'); 249 250 this.initialize(); 251 252 $( document.body ).on('updated_cart_totals', () => this.reInitialize()); 253 } 254 255 /** 256 * Handles setting up Wallet on the checkout page. 257 */ 258 handleCheckoutPage() { 259 this.debugLog('Initializing the checkout page'); 260 261 $( document.body ).on('updated_checkout', () => this.reInitialize()); 262 } 263 264 /** 265 * Initializes Wallet. 266 */ 267 initialize(products = null) { 268 269 if (this.initializing) { 270 return; 271 } 272 273 this.initializing = true; 274 275 this.getPaymentRequest(products).then((paymentRequest) => { 276 277 this.collect = new TokenizeJs(this.businessId, this.appId, paymentRequest); 278
279 this.collect.supportWalletPayments().then(result => { 280 281 const supportedWallets = Object.keys(this.enabledButtons).filter(walletId => result[this.convertStringToCamelCase(walletId)]) 282 const unSupportedWallets = Object.keys(this.enabledButtons).filter(walletId => !result[this.convertStringToCamelCase(walletId)]) 283 284 if (supportedWallets.length) { 285 286 this.debugLog(`${supportedWallets.map(this.convertSnakeCaseToCapitalizedString).join(', ')} supported, mounting...`); 287 288 this.collect.mount('mwc-payments-wallet-buttons', document, { 289 paymentMethods: supportedWallets, 290 buttonOptions: { 291 onClick: event => this.handleWalletButtonClick(event) 292 }, 293 applePayButtonOptions: this.enabledButtons['apple_pay'] ?? {}, 294 googlePayButtonOptions: this.enabledButtons['google_pay'] ?? {}, 295 }); 296 } 297 298 if (unSupportedWallets.length) { 299 300 this.debugLog(`${unSupportedWallets.map(this.convertSnakeCaseToCapitalizedString).join(', ')} not supported.`); 301 302 if (!supportedWallets.length) { 303 this.hideUI(); 304 } 305 } 306 307 }); 308 309 this.initializeListeners(); 310 311 }).catch((data) => { 312 313 this.debugLog('Could not load payment request', data); 314 315 this.initializing = false; 316 }); 317 } 318 319 /** 320 * Initializes all of the event listeners. 321 */ 322 initializeListeners() { 323 324 // fires when Wallet is ready 325 this.collect.on('ready', event => { 326 this.handleReady(event); 327 } ); 328 329 // fires when the Wallet shipping address has been changed 330 this.collect.on('shipping_address_change', event => { 331 this.handleShippingAddressChanged(event); 332 } ); 333 334 // fires when the Wallet payment method has been changed 335 this.collect.on('payment_method_change', event => { 336 this.handlePaymentMethodChange(event); 337 } ); 338 339 // fires when the Wallet shipping method has been changed 340 this.collect.on('shipping_method_change', event => { 341 this.handleShippingMethodChange(event); 342 } ); 343 344 // fires when the Wallet coupon code has been changed 345 this.collect.on('coupon_code_change', event => { 346 this.handleCouponCodeChange(event); 347 } ); 348 349 // fires when Wallet has been authorized 350 this.collect.on('payment_authorized', event => { 351 this.handlePaymentAuthorized(event); 352 } ); 353 354 // fires when there is an error 355 this.collect.on('error', error => { 356 this.handleError(error); 357 } ); 358 359 // fires when the wallet is closed 360 this.collect.on('close_wallet', event => { 361 this.debugLog('Wallet closed', event) 362 } ); 363 } 364 365 /** 366 * Tears down Wallet. 367 */ 368 tearDown() { 369 370 if (this.initialized) { 371 this.collect.unmount('mwc-payments-wallet-buttons', document); 372 this.initialized = false; 373 } 374 375 this.hideUI(); 376 } 377 378 /** 379 * Re-initializes Wallet. 380 */ 381 reInitialize(products = null) { 382 383 this.tearDown(); 384 385 if (this.businessId && this.appId && ! this.initializing) { 386 this.initialize(products); 387 } 388 } 389 390 /** 391 * Handles the "ready" event. 392 * 393 * @param {Object} event 394 */ 395 handleReady(event) { 396 this.initializing = false; 397 this.initialized = true; 398 399 this.debugLog('Wallet is ready', event); 400 401 this.showUI(); 402 } 403 404 /** 405 * Handles the wallet button onClick event. 406 * 407 * @param {Object} event 408 */ 409 handleWalletButtonClick(event) { 410 this.debugLog(`${this.convertSnakeCaseToCapitalizedString(event.source)} button clicked`, event); 411 412 // When customer opens the Wallet button on a single product page, the product is not in the cart, yet. We 413 // need to update the cart with the product on the current page as soon as the wallet sheet opens to ensure 414 // when the payment is authorized, the session cart includes the product currently being viewed/purchased. 415 // Note that this operation replaces any previous items in cart. 416 if (this.isProductPage()) { 417 this.handleProductPageWalletButtonClick(event) 418 } else { 419 this.startWalletSession(event.source) 420 } 421 } 422 423 /** 424 * Starts the wallet session for the given wallet, optionally using a payment request 425 * 426 * @param walletId 427 * @param paymentRequest 428 */ 429 startWalletSession(walletId, paymentRequest = {}) { 430 this.collect[{ 431 apple_pay: 'startApplePaySession', 432 google_pay: 'startGooglePaySession' 433 }[walletId]](paymentRequest) 434 } 435 436 /** 437 * Handles the "shipping_address_change" event. 438 * 439 * @param {Object} event 440 */ 441 handleShippingAddressChanged(event) { 442 this.debugLog('The shipping address has been changed', event); 443 444 this.updateCart({ 445 customer: { 446 shippingAddress: this.getAdaptedAddress(event.shippingAddress) 447 } 448 }).then(() => { 449 this.debugLog('Cart updated') 450 451 this.getPaymentRequest().then((paymentRequest) => { 452 453 this.debugLog('Payment request updated', paymentRequest) 454 455 event.updateWith(paymentRequest) 456 }) 457 }).catch(err => { 458 this.debugLog('Failed to update cart', err) 459 this.handleApiError(err, event); 460 }) 461 } 462 463 /** 464 * Handles API errors. 465 * 466 * @param {*} err 467 * @param {Object} event 468 */ 469 handleApiError(err, event) { 470 471 const data = { 472 error: { 473 message: err.message 474 } 475 } 476 const errCode = err.code.toLowerCase() // error codes must use lowercase snake_case 477 478 // Pass errors to Apple Pay or Google Pay if possible 479 if ([ 480 "invalid_shipping_address", 481 "invalid_billing_address", 482 "invalid_coupon_code", 483 "expired_coupon_code", 484 "unserviceable_address", 485 "unknown" 486 ].indexOf(errCode) >= 0) { 487 data.error.code = errCode 488
489 if (err.data?.field && ['INVALID_BILLING_ADDRESS', 'INVALID_SHIPPING_ADDRESS'].indexOf(err.code) > -1) { 490 // contrary to the error.code field above, Poynt expects the contactField in camelCase 491 data.error.contactField = this.convertStringToCamelCase(err.data.field) 492 } 493 494 // otherwise, render a generic error and pass unknown error to Apple Pay or Google Pay 495 } else { 496 this.collect.abortApplePaySession() 497 498 this.renderErrors([err.message]) 499 500 return; 501 } 502 503 event.complete ? event.complete(data) : event.updateWith(data) 504 } 505 506 /** 507 * Handles the "payment_method_change" event. 508 * 509 * @param {Object} event 510 */ 511 handlePaymentMethodChange(event) { 512 this.debugLog('The payment method has been changed', event); 513 514 // Currently, this method is a no-op, but we still have to call event.updateWith with an empty object to 515 // avoid Apple Pay UI timing out. 516 event.updateWith({}); 517 } 518 519 /** 520 * Handles the "shipping_method_change" event. 521 * 522 * @param {Object} event 523 */ 524 handleShippingMethodChange(event) { 525 this.debugLog('The shipping method has been changed', event); 526 527 this.updateCart({ 528 customer: { 529 shippingMethod: event.shippingMethod.id 530 } 531 }).then(() => { 532 this.debugLog('Cart updated') 533 534 this.getPaymentRequest().then((paymentRequest) => { 535 536 this.debugLog('Payment request updated', paymentRequest) 537 538 event.updateWith(paymentRequest) 539 }) 540 }).catch(err => { 541 this.debugLog('Failed to update cart', err) 542 this.handleApiError(err, event) 543 }) 544 } 545 546 /** 547 * Handles the "coupon_code_change" event. 548 * 549 * @param {Object} event 550 */ 551 handleCouponCodeChange(event) { 552 this.debugLog('The coupon code has been changed', event); 553 554 this.updateCart({ 555 couponCode: event.couponCode || '' 556 }).then(() => { 557 this.debugLog('Cart updated') 558 559 this.getPaymentRequest().then((paymentRequest) => { 560 561 this.debugLog('Payment request updated', paymentRequest) 562 563 // If the coupon was removed, we need to pass in an empty coupon object here, so that Poynt Collect can remove it. 564 // TODO: refactor this once the bug in Poynt Collect is fixed {@itambek 2022-11-11} 565 if (!paymentRequest.couponCode) { 566 paymentRequest.couponCode = { 567 code: "", 568 label: "", 569 amount: "0.00", 570 } 571 } 572 573 event.updateWith(paymentRequest) 574 }) 575 }).catch(err => { 576 this.debugLog('Failed to update cart', err) 577 this.handleApiError(err, event) 578 }) 579 } 580 581 /** 582 * Handles the "payment_authorized" event. 583 * 584 * @param {Object} event 585 */ 586 handlePaymentAuthorized(event) { 587 this.debugLog('Payment has been authorized', event); 588 589 let data = { 590 billingAddress: this.getAdaptedAddress(event.billingAddress), 591 shippingAddress: this.getAdaptedAddress(event.shippingAddress), 592 }; 593 594 let emailAddress = event.shippingAddress?.emailAddress || event.billingAddress?.emailAddress 595 596 // set the email address (only if it's available - otherwise we might overwrite a valid email address already present in cart) 597 if (emailAddress) { 598 data.emailAddress = emailAddress 599 } 600 601 // Poynt provides the phone number as a part of shipping address, whereas WooCommerce expects it as part of 602 // billing address, so we copy it from shippingAddress to billingAddress 603 if (data.shippingAddress?.phone && !data.billingAddress?.phone) { 604 data.billingAddress.phone = data.shippingAddress.phone 605 } 606 607 this.updateCart({ 608 customer: data 609 }).then(() => { 610 this.debugLog('Cart updated') 611
612 this.makeApiRequest('POST', 'payments/godaddy-payments/wallets/processing/pay', { 613 nonce: event.nonce, 614 source: event.source, 615 }).then(res => { 616 this.debugLog('Payment created', res) 617 618 event.complete(); 619 620 window.location.replace(res.redirectUrl) 621 }).catch(err => { 622 this.debugLog('Failed to create payment', err) 623 this.handleApiError(err, event) 624 }) 625 }).catch(err => { 626 this.debugLog('Failed to update cart', err) 627 this.handleApiError(err, event) 628 }) 629 } 630 631 /** 632 * Handles the error event data. 633 * 634 * Logs errors to console and maybe renders them in a user-facing notice. 635 * 636 * @param {Object} event after a form error 637 */ 638 handleError(event) { 639 640 this.debugLog('Wallet error', event); 641 642 let errorMessage = poyntPaymentFormI18n.errorMessages.genericError; 643 644 // Poynt Collect API has some inconsistency about error message response data: 645 if ( 'error' === event.type && event.data ) { 646 if ( event.data.error && event.data.error.message && event.data.error.message.message ) { 647 errorMessage = event.data.error.message.message; 648 } else if ( event.data.message ) { 649 errorMessage = event.data.message; 650 } else if ( event.data.error && event.data.error.message && event.data.error.source && 'submit' === event.data.error.source ) { 651 errorMessage = event.data.error.message; 652 } else if ( event.data.error ) { 653 errorMessage = event.data.error; 654 } 655 } 656 657 if (errorMessage.includes('Request failed')) { 658 errorMessage = poyntPaymentFormI18n.errorMessages.genericError; 659 } 660 661 this.renderErrors([errorMessage]) 662 } 663 664 /** 665 * Logs an item to console if logging is enabled. 666 * 667 * @param {String} message 668 * @param {Object|null} data 669 */ 670 debugLog(message, data = null) { 671 672 if (! this.isLoggingEnabled) { 673 return; 674 } 675 676 console.log('[Wallets] '+message); 677 678 if (null !== data) { 679 console.log(data); 680 } 681 } 682 683 /** 684 * Renders errors to the customer. 685 * 686 * @param errors 687 */ 688 renderErrors(errors) { 689 $( '.woocommerce-error, .woocommerce-message' ).remove(); 690 691 this.uiElement.prepend('<ul class="woocommerce-error"><li>' + errors.join( '</li><li>' ) + '</li></ul>'); 692 this.uiElement.removeClass( 'processing' ).unblock(); 693 694 $('html, body').animate({scrollTop: this.uiElement.offset().top - 100}, 1000); 695 } 696 697 /** 698 * Hides the Wallet UI. 699 */ 700 hideUI() { 701 // NOTE: add more advanced handling when multiple buttons are supported 702 $('.mwc-external-checkout-buttons, .mwc-external-checkout-buttons-divider').hide().removeClass('available'); 703 } 704 705 /** 706 * Shows the Wallet UI. 707 */ 708 showUI() { 709 // NOTE: add more advanced handling when multiple buttons are supported 710 $('.mwc-external-checkout-buttons, .mwc-external-checkout-buttons-divider').show().css('display', 'block').addClass('available'); 711 } 712 713 /** 714 * Blocks the current UI element 715 */ 716 blockUI() { 717 this.uiElement.block({ 718 message: null, 719 overlayCSS: { 720 background: '#fff', 721 opacity: 0.6, 722 } 723 }); 724 } 725 726 /** 727 * Unblocks the current UI element 728 */ 729 unblockUI() { 730 this.uiElement.unblock(); 731 } 732 733 /** 734 * Gets the initial payment request. 735 * 736 * @returns Promise 737 */ 738 getPaymentRequest(products = null) {
739 return this.makeApiRequest('GET', 'payments/godaddy-payments/wallets/request', products ? { products } : null); 740 } 741 742 /** 743 * Updates the WooCommerce cart with the given data. 744 * 745 * @returns Promise 746 */ 747 updateCart(data) { 748 return this.makeApiRequest('PATCH', 'cart', data); 749 } 750 751 /** 752 * Makes a request to the site REST API. 753 * 754 * @param {String} method 755 * @param {String} route 756 * @param {Object} data 757 * 758 * @return Promise 759 */ 760 makeApiRequest(method = 'GET', route, data = {}) { 761 762 return new Promise((resolve, reject) => { 763 $.ajax({ 764 url: this.apiUrl+'godaddy/mwc/v1/'+route, 765 dataType: 'json', 766 method: method, 767 headers: { 768 'X-MWC-Payments-Nonce': this.apiNonce 769 }, 770 data: data 771 }).done((data) => { 772 resolve(data); 773 }).fail((jqXHR) => { 774 reject(jqXHR.responseJSON); 775 }); 776 }); 777 } 778 779 780 /** 781 * Converts a string to camelCase 782 * 783 * @param {String} str 784 * 785 * @return String 786 */ 787 convertStringToCamelCase = str => { 788 return str.toLowerCase().replace(/[-_][a-z0-9]/g, group => 789 group.toUpperCase() 790 .replace('-', '') 791 .replace('_', '') 792 ); 793 }; 794 795 796 /** 797 * Converts snake_case to capitalized string 798 * 799 * @param {String} str 800 * 801 * @return String 802 */ 803 convertSnakeCaseToCapitalizedString = str => { 804 return str.split('_').map((word) => { 805 return word[0].toUpperCase() + word.substring(1); 806 }).join(' '); 807 }; 808 809 810 /** 811 * Converts the address provided by Wallet to the format required by GDP. 812 * 813 * @param {Object} address 814 * @see https://developer.apple.com/documentation/apple_pay_on_the_web/applepaypaymentcontact 815 * 816 * @return Object 817 */ 818 getAdaptedAddress(address) { 819 return address ? { 820 countryCode: address.countryCode, 821 locality: address.locality, 822 postalCode: address.postalCode, 823 firstName: address.givenName ?? address.name?.split(' ')[0], 824 lastName: address.familyName ?? address.name?.split(' ').slice(1).join(' '), 825 lines: address.addressLines, 826 phone: address.phoneNumber, 827 administrativeDistricts: [address.administrativeArea, address.subAdministrativeArea], 828 } : null 829 } 830 } 831 832 // dispatch loaded event 833 $( document.body ).trigger( 'mwc_payments_wallets_handler_loaded' ); 834 835} );
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.