diff --git a/js/crm.payment.js b/js/crm.payment.js index 062fff0b852f836d930a5098261bd7a94301595d..2e00225a5c3ba91565ce31ec8f865405f296c022 100644 --- a/js/crm.payment.js +++ b/js/crm.payment.js @@ -1,8 +1,9 @@ (function($) { var payment = { - form: null, scriptName: 'CRM.payment', + form: null, + submitButtons: null, /** * Get the total amount on the form @@ -11,7 +12,7 @@ getTotalAmount: function() { var totalAmount = 0.0; if (CRM.payment.isEventAdditionalParticipants()) { - // We MUST return null because 0.0 is treated as a non-stripe submit. + // We MUST return null because 0.0 is treated as a non-processor submit. // In this case the amount is not 0, we just don't know what it is yet. totalAmount = null; } @@ -22,7 +23,7 @@ else if (typeof calculateTotalFee == 'function') { // This is ONLY triggered in the following circumstances on a CiviCRM contribution page: // - With a priceset that allows a 0 amount to be selected. - // - When Stripe is the ONLY payment processor configured on the page. + // - When we are the ONLY payment processor configured on the page. totalAmount = parseFloat(calculateTotalFee()); } else if (this.getIsDrupalWebform()) { @@ -88,6 +89,8 @@ }, /** + * Is the event registering additional participants (this means we do not know the full amount) + * * @returns {boolean} */ isEventAdditionalParticipants: function() { @@ -99,6 +102,11 @@ return false; }, + /** + * Are we currently loaded on a drupal webform? + * + * @returns {boolean} + */ getIsDrupalWebform: function() { // form class for drupal webform: webform-client-form (drupal 7); webform-submission-form (drupal 8) if (this.form !== null) { @@ -107,33 +115,45 @@ return false; }, + /** + * Get the Billing Form as a DOM/HTMLElement. + * Also set the "form" property on CRM.payment. + * + * @returns {HTMLElement} + */ getBillingForm: function() { - // If we have a stripe billing form on the page - var billingFormID = $('div#card-element').closest('form').prop('id'); + // If we have a billing form on the page with our processor + var billingFormID = $('div#crm-payment-js-billing-form-container').closest('form').prop('id'); if ((typeof billingFormID === 'undefined') || (!billingFormID.length)) { - // If we have multiple payment processors to select and stripe is not currently loaded + // If we have multiple payment processors to select and we are not currently loaded billingFormID = $('input[name=hidden_processor]').closest('form').prop('id'); } // We have to use document.getElementById here so we have the right elementtype for appendChild() - return document.getElementById(billingFormID); + this.form = document.getElementById(billingFormID); + return this.form; }, + /** + * Get all the billing submit buttons on the form as DOM elements + * Also set the "submitButtons" property on CRM.payment. + * + * @returns {NodeList} + */ getBillingSubmit: function() { - var submit = null; if (CRM.payment.getIsDrupalWebform()) { - submit = this.form.querySelectorAll('[type="submit"].webform-submit'); - if (submit.length === 0) { + this.submitButtons = this.form.querySelectorAll('[type="submit"].webform-submit'); + if (this.submitButtons.length === 0) { // drupal 8 webform - submit = this.form.querySelectorAll('[type="submit"].webform-button--submit'); + this.submitButtons = this.form.querySelectorAll('[type="submit"].webform-button--submit'); } } else { - submit = this.form.querySelectorAll('[type="submit"].validate'); + this.submitButtons = this.form.querySelectorAll('[type="submit"].validate'); } - if (submit.length === 0) { + if (this.submitButtons.length === 0) { this.debugging(this.scriptName, 'No submit button found!'); } - return submit; + return this.submitButtons; }, /** @@ -253,12 +273,213 @@ * @returns {bool} */ isAJAXPaymentForm: function(url) { + // /civicrm/payment/form? occurs when a payproc is selected on page + // /civicrm/contact/view/participant occurs when payproc is first loaded on event credit card payment + // On wordpress these are urlencoded return (url.match("civicrm(\/|%2F)payment(\/|%2F)form") !== null) || (url.match("civicrm(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)participant") !== null) || (url.match("civicrm(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)membership") !== null) || (url.match("civicrm(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)contribution") !== null); }, + /** + * Call this function before submitting the form to CiviCRM (if you ran setBillingFieldsRequiredForJQueryValidate()). + * The "name" parameter on a group of checkboxes where at least one must be checked must be the same or validation will require all of them! + * Reset the name of the checkboxes before submitting otherwise CiviCRM will not get the checkbox values. + */ + resetBillingFieldsRequiredForJQueryValidate: function() { + $('div#priceset input[type="checkbox"], fieldset.crm-profile input[type="checkbox"], #on-behalf-block input[type="checkbox"]').each(function() { + if ($(this).attr('data-name') !== undefined) { + $(this).attr('name', $(this).attr('data-name')); + } + }); + }, + + /** + * Call this function before running jQuery validation + * + * CustomField checkboxes in profiles do not get the "required" class. + * This should be fixed in CRM_Core_BAO_CustomField::addQuickFormElement but requires that the "name" is fixed as well. + */ + setBillingFieldsRequiredForJQueryValidate: function() { + $('div.label span.crm-marker').each(function() { + $(this).closest('div').next('div').find('input[type="checkbox"]').addClass('required'); + }); + + // The "name" parameter on a set of checkboxes where at least one must be checked must be the same or validation will require all of them! + // Checkboxes for custom fields are added as quickform "advcheckbox" which seems to require a unique name for each checkbox. But that breaks + // jQuery validation because each checkbox in a required group must have the same name. + // We store the original name and then change it. resetBillingFieldsRequiredForJQueryValidate() must be called before submit. + // Most checkboxes get names like: "custom_63[1]" but "onbehalf" checkboxes get "onbehalf[custom_63][1]". We change them to "custom_63" and "onbehalf[custom_63]". + $('div#priceset input[type="checkbox"], fieldset.crm-profile input[type="checkbox"], #on-behalf-block input[type="checkbox"]').each(function() { + var name = $(this).attr('name'); + $(this).attr('data-name', name); + $(this).attr('name', name.replace('[' + name.split('[').pop(), '')); + }); + + // Default email validator accepts test@example but on test@example.org is valid (https://jqueryvalidation.org/jQuery.validator.methods/) + $.validator.methods.email = function(value, element) { + // Regex from https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address + return this.optional(element) || /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(value); + }; + }, + + /** + * Drupal webform requires a custom element to determine how it should process the submission + * or if it should do another action. Because we trigger submit via javascript we have to add this manually. + * @param submitAction + */ + addDrupalWebformActionElement: function(submitAction) { + var hiddenInput = null; + if (document.getElementById('action') !== null) { + hiddenInput = document.getElementById('action'); + } + else { + hiddenInput = document.createElement('input'); + } + hiddenInput.setAttribute('type', 'hidden'); + hiddenInput.setAttribute('name', 'op'); + hiddenInput.setAttribute('id', 'action'); + hiddenInput.setAttribute('value', submitAction); + this.form.appendChild(hiddenInput); + }, + + /** + * Delegate to standard form submission (we do not submit via our javascript processing). + * @returns {*} + */ + doStandardFormSubmit: function() { + // Disable the submit button to prevent repeated clicks + for (i = 0; i < this.submitButtons.length; ++i) { + this.submitButtons[i].setAttribute('disabled', true); + } + this.resetBillingFieldsRequiredForJQueryValidate(); + return this.form.submit(); + }, + + /** + * Validate a reCaptcha if it exists on the form. + * Ideally we would use grecaptcha.getResponse() but the reCaptcha is already + * render()ed by CiviCRM so we don't have clientID and can't be sure we are + * checking the reCaptcha that is on our form. + * + * @returns {boolean} + */ + validateReCaptcha: function() { + if (typeof grecaptcha === 'undefined') { + // No reCaptcha library loaded + this.debugging(this.scriptName, 'reCaptcha library not loaded'); + return true; + } + if ($(this.form).find('[name=g-recaptcha-response]').length === 0) { + // no reCaptcha on form - we check this first because there could be reCaptcha on another form on the same page that we don't want to validate + this.debugging(this.scriptName, 'no reCaptcha on form'); + return true; + } + if ($(this.form).find('[name=g-recaptcha-response]').val().length > 0) { + // We can't use grecaptcha.getResponse because there might be multiple reCaptchas on the page and we might not be the first one. + this.debugging(this.scriptName, 'recaptcha is valid'); + return true; + } + this.debugging(this.scriptName, 'recaptcha active and not valid'); + $('div#card-errors').hide(); + this.swalFire({ + icon: 'warning', + text: '', + title: ts('Please complete the reCaptcha') + }, '.recaptcha-section', true); + this.triggerEvent('crmBillingFormNotValid'); + this.form.dataset.submitted = 'false'; + return false; + }, + + /** + * This adds handling for the CiviDiscount extension "apply" button. + * A better way should really be found. + */ + addSupportForCiviDiscount: function() { + // Add a keypress handler to set flag if enter is pressed + cividiscountElements = this.form.querySelectorAll('input#discountcode'); + var cividiscountHandleKeydown = function(event) { + if (event.code === 'Enter') { + event.preventDefault(); + this.debugging(this.scriptName, 'adding submitdontprocess'); + this.form.dataset.submitdontprocess = 'true'; + } + }; + + for (i = 0; i < cividiscountElements.length; ++i) { + cividiscountElements[i].addEventListener('keydown', cividiscountHandleKeydown); + } + }, + + /** + * Display an error for the payment element + * + * @param {string} errorMessage - the error string + * @param {boolean} notify - whether to popup a notification as well as + * display on the form. + */ + displayError: function(errorMessage, notify) { + // Display error.message in your UI. + this.debugging(this.scriptName, 'error: ' + errorMessage); + // Inform the user if there was an error + var errorElement = document.getElementById('card-errors'); + errorElement.style.display = 'block'; + errorElement.textContent = errorMessage; + this.form.dataset.submitted = 'false'; + if (this.submitButtons !== null) { + for (i = 0; i < this.submitButtons.length; ++i) { + this.submitButtons[i].removeAttribute('disabled'); + } + } + this.triggerEvent('crmBillingFormNotValid'); + if (notify) { + this.swalClose(); + CRM.payment.swalFire({ + icon: 'error', + text: errorMessage, + title: '' + }, '#card-element', true); + } + }, + + /** + * Wrapper around Swal.fire() + * @param {array} parameters + * @param {string} scrollToElement + * @param {boolean} fallBackToAlert + */ + swalFire: function(parameters, scrollToElement, fallBackToAlert) { + if (typeof Swal === 'function') { + if (scrollToElement.length > 0) { + parameters.onAfterClose = function() { window.scrollTo($(scrollToElement).position()); }; + } + Swal.fire(parameters); + } + else if (fallBackToAlert) { + window.alert(parameters.title + ' ' + parameters.text); + } + }, + + /** + * Wrapper around Swal.close() + */ + swalClose: function() { + if (typeof Swal === 'function') { + Swal.close(); + } + }, + + /** + * Trigger a jQuery event + * @param {string} event + */ + triggerEvent: function(event) { + this.debugging(this.scriptName, 'Firing Event: ' + event); + $(this.form).trigger(event); + }, + /** * Output debug information * @param {string} scriptName @@ -288,7 +509,7 @@ document.addEventListener('DOMContentLoaded', function() { CRM.payment.debugging(CRM.payment.scriptName, 'loaded via DOMContentLoaded'); - CRM.payment.form = CRM.payment.getBillingForm(); + CRM.payment.getBillingForm(); }); // Re-prep form when we've loaded a new payproc via ajax or via webform @@ -298,7 +519,7 @@ // On wordpress these are urlencoded if (CRM.payment.isAJAXPaymentForm(settings.url)) { CRM.payment.debugging(CRM.payment.scriptName, 'triggered via ajax'); - CRM.payment.form = CRM.payment.getBillingForm(); + CRM.payment.getBillingForm(); } });