1/** 2 * Charitable Universal Modal 3 * 4 * Provides modal donation forms for any campaign that can be triggered from anywhere. 5 * Based on proven campaign selector patterns for reliable AJAX form loading. 6 * 7 * @package Charitable Pro 8 * @since 1.8.12.1 9 */ 10 11(function($) { 12 'use strict'; 13 14 /** 15 * Debug-only logger. Set window.CHARITABLE_DEBUG = true to enable. 16 */ 17 function debugLog() { 18 if ( window.CHARITABLE_DEBUG ) { 19 console.log.apply( console, arguments ); 20 } 21 } 22 function debugWarn() { 23 if ( window.CHARITABLE_DEBUG ) { 24 console.warn.apply( console, arguments ); 25 } 26 } 27 function debugError() { 28 if ( window.CHARITABLE_DEBUG ) { 29 console.error.apply( console, arguments ); 30 } 31 } 32 33 var CharitableUniversalModal = { 34 35 /** 36 * Configuration from localized script. 37 */ 38 config: window.CHARITABLE_UNIVERSAL_MODAL || {}, 39 40 /** 41 * Track if a request is in progress. 42 */ 43 isLoading: false, 44 45 /** 46 * Track loaded CSS files to avoid duplicates. 47 */ 48 loadedCss: {}, 49 50 /** 51 * Whether to hide the campaign selector after form load. 52 */ 53 hideCampaignSelector: false, 54 55 /** 56 * Initialize the universal modal system. 57 */ 58 init: function() { 59 this.bindEvents(); 60 this.setupModal(); 61 debugLog('[Universal Modal] Initialized successfully'); 62 }, 63 64 /** 65 * Bind event handlers. 66 */ 67 bindEvents: function() { 68 var self = this; 69 70 // Handle modal trigger clicks 71 $(document).on('click', '.charitable-modal-trigger', function(e) { 72 e.preventDefault(); 73 var $trigger = $(this); 74 var campaignId = $trigger.data('campaign-id'); 75 var modalClass = $trigger.data('modal-class') || 'charitable-universal-modal'; 76 var amount = $trigger.data('amount'); 77 var period = $trigger.data('period') || 'once'; 78 var campaignName = $trigger.data('campaign-name'); 79 var campaignThumbnail = $trigger.data('campaign-thumbnail'); 80 81 // Detect campaign showcase context (donate button inside Campaign Showcase block). 82 var fromShowcase = $trigger.closest('.wp-block-charitable-campaign-showcase').length > 0; 83 84 // For custom amount input: read value from sibling input. 85 if (!amount && $trigger.hasClass('charitable-quick-donate-custom-btn')) { 86 amount = $trigger.closest('.campaign-quick-donate--custom') 87 .find('.charitable-quick-donate-amount-input').val(); 88 } 89 90 // Check if trigger wants to hide the campaign selector. 91 self.hideCampaignSelector = !!$trigger.data('hide-campaign-selector'); 92 93 debugLog('[Universal Modal] Trigger clicked - Campaign ID: ' + campaignId + ', Amount: ' + (amount || 'none') + ', Loading state: ' + self.isLoading); 94 95 if (campaignId) { 96 debugLog('[Universal Modal] Opening modal for campaign ' + campaignId); 97 self.openModal(campaignId, modalClass, amount, campaignName, campaignThumbnail, fromShowcase, period); 98 } else { 99 debugWarn('[Universal Modal] No campaign ID found on trigger element'); 100 } 101 }); 102 103 // Handle modal close buttons - matches existing lean-modal behavior 104 $(document).on('click', '#charitable-universal-modal .modal-close, #lean_overlay', function(e) { 105 e.preventDefault(); 106 self.closeModal(); 107 }); 108 109 // Handle ESC key to close modal (modern jQuery syntax) 110 $(document).on('keyup', function(e) { 111 if (e.key === 'Escape' || e.keyCode === 27) { // ESC key 112 self.closeModal(); 113 } 114 }); 115 }, 116 117 /**
118 * Setup the modal HTML structure - matches existing charitable modal system. 119 */ 120 setupModal: function() { 121 // Create shared lean overlay if it doesn't exist 122 if ($('#lean_overlay').length === 0) { 123 $('body').append('<div id="lean_overlay"></div>'); 124 } 125 126 // Only create our modal if it doesn't exist 127 if ($('#charitable-universal-modal').length === 0) { 128 // Use exact same structure as existing charitable modal 129 var modalHtml = '<div id="charitable-universal-modal" style="display: none;" class="charitable-modal">' + 130 '<a class="modal-close"></a>' + 131 '<div class="donation-form-wrapper"></div>' + 132 '</div>'; 133 134 $('body').append(modalHtml); 135 debugLog('[Universal Modal] Modal structure created with existing charitable-modal structure'); 136 } 137 }, 138 139 /** 140 * Open the modal and load campaign form - matches existing lean-modal behavior. 141 * 142 * @param {number} campaignId The campaign ID to load. 143 * @param {string} modalClass Optional modal class for styling. 144 * @param {string} amount Optional preset amount. 145 * @param {string} campaignName Optional campaign name for header. 146 * @param {string} campaignThumbnail Optional thumbnail URL for header. 147 * @param {boolean} fromShowcase True if opened from Campaign Showcase block (adds extra class). 148 * @param {string} period Optional donation period: 'monthly' or 'once'. 149 */ 150 openModal: function(campaignId, modalClass, amount, campaignName, campaignThumbnail, fromShowcase, period) { 151 var $modal = $('#charitable-universal-modal'); 152 var $wrapper = $modal.find('.donation-form-wrapper');
153 var $overlay = $('#lean_overlay'); 154 155 if (this.isLoading) { 156 debugLog('[Universal Modal] Already loading, ignoring request'); 157 return; 158 } 159 160 // Reset modal state before opening 161 this.resetModal(); 162 163 // Store amount, period, and campaign name for AJAX request (after reset so they aren't cleared). 164 this.currentAmount = amount || ''; 165 this.currentPeriod = (period === 'monthly') ? 'monthly' : 'once'; 166 this.currentCampaignName = campaignName || ''; 167 168 // Show campaign name header if provided. 169 if (campaignName) { 170 var headerStyle = ''; 171 var headerClass = 'charitable-modal-campaign-name'; 172 if (campaignThumbnail) { 173 headerStyle = ' style="background-image: url(' + encodeURI( campaignThumbnail ) + ')"'; 174 headerClass += ' has-thumbnail'; 175 } else { 176 headerClass += ' no-thumbnail'; 177 } 178 $wrapper.before('<div class="' + headerClass + '"' + headerStyle + '><span>' + $('<span>').text(campaignName).html() + '</span></div>'); 179 } 180 181 // Show loading state 182 this.showLoading($wrapper); 183 184 // Show overlay with lean-modal styling (matches existing system) 185 $overlay.css({ 186 'position': 'fixed', 187 'z-index': '99999', 188 'top': '0px', 189 'left': '0px', 190 'height': '100%', 191 'width': '100%', 192 'background': '#000', 193 'opacity': '0.5' 194 }).fadeIn(200); 195 196 // Show modal with proper classes (matches existing charitable-modal) 197 $modal.removeClass().addClass('charitable-modal charitable-modal-donation'); 198 if (modalClass) { 199 $modal.addClass(modalClass); 200 } 201 // Add context class when opened from Campaign Showcase donate button only. 202 if (fromShowcase) { 203 $modal.addClass('charitable-modal-from-showcase'); 204 } 205 206 // Position modal fixed to viewport 207 $modal.css({ 208 'position': 'fixed', 209 'top': '100px', 210 'left': '50%', 211 'transform': 'translateX(-50%)', 212 'z-index': '100000' 213 }).fadeIn(200); 214 215 $('body').addClass('charitable-modal-open'); 216 217 // Load campaign form via AJAX 218 this.loadCampaignForm(campaignId, $wrapper); 219 }, 220 221 /** 222 * Close the modal - matches existing lean-modal behavior. 223 */ 224 closeModal: function() { 225 var self = this; 226 var $modal = $('#charitable-universal-modal');
227 var $overlay = $('#lean_overlay'); 228 229 // Fade out modal and overlay (matches lean-modal animation) 230 $modal.fadeOut(200); 231 $overlay.fadeOut(200); 232 $('body').removeClass('charitable-modal-open'); 233 234 // Reset modal state after animation completes 235 setTimeout(function() { 236 self.resetModal(); 237 }, 200); 238 239 debugLog('[Universal Modal] Modal closed with lean-modal behavior'); 240 }, 241 242 /** 243 * Reset modal to clean state - matches existing charitable-modal structure. 244 */ 245 resetModal: function() { 246 var $modal = $('#charitable-universal-modal'); 247 var $wrapper = $modal.find('.donation-form-wrapper'); 248 249 // Remove campaign name header if present. 250 $modal.find('.charitable-modal-campaign-name').remove(); 251 252 // Clear content 253 $wrapper.empty(); 254 255 // Reset classes to base state (matches existing system) 256 $modal.removeClass().addClass('charitable-modal charitable-modal-donation'); 257 258 // Reset inline styles 259 $modal.removeAttr('style').css('display', 'none'); 260 261 // Reset loading state 262 this.isLoading = false; 263 this.hideCampaignSelector = false; 264 this.currentAmount = ''; 265 this.currentPeriod = ''; 266 this.currentCampaignName = ''; 267 268 debugLog('[Universal Modal] Modal state reset to charitable-modal standards'); 269 }, 270 271 /** 272 * Show loading state in modal. 273 * 274 * @param {jQuery} $wrapper The wrapper element. 275 */ 276 showLoading: function($wrapper) { 277 var loadingHtml = '<div class="charitable-modal-loading">' + 278 '<img src="' + (this.config.loading_gif || '/wp-includes/images/spinner.gif') + '" alt="Loading..." />' + 279 '<p>Loading donation form...</p>' + 280 '</div>'; 281 282 $wrapper.html(loadingHtml); 283 this.isLoading = true; 284 }, 285 286 /** 287 * Load campaign form via AJAX - copied from campaign selector. 288 * 289 * @param {number} campaignId The campaign ID. 290 * @param {jQuery} $wrapper The wrapper element. 291 */ 292 loadCampaignForm: function(campaignId, $wrapper) { 293 var self = this; 294 295 debugLog('[Universal Modal] AJAX request starting for campaign ' + campaignId); 296 297 $.ajax({ 298 type: 'POST', 299 url: this.config.ajax_url || (typeof ajaxurl !== 'undefined' ? ajaxurl : '/wp-admin/admin-ajax.php'), 300 data: { 301 action: 'get_donation_form', 302 campaign_id: campaignId, 303 amount: self.currentAmount || '', 304 period: self.currentPeriod || 'once' 305 }, 306 dataType: 'json', 307 xhrFields: { 308 withCredentials: true 309 }, 310 success: function(response) { 311 debugLog('[Universal Modal] AJAX success - response.success: ' + (response.success ? 'true' : 'false')); 312 313 if (response.success && response.data) { 314 debugLog('[Universal Modal] Response data length: ' + response.data.length + ' characters'); 315 self.replaceForm(response.data, $wrapper, campaignId); 316 } else { 317 debugError('[Universal Modal] AJAX response failed or no data', response); 318 self.showError($wrapper); 319 } 320 }, 321 error: function(xhr, status, error) { 322 debugError('[Universal Modal] AJAX Error:', error, 'Status:', status); 323 debugError('[Universal Modal] XHR Response:', xhr.responseText); 324 self.showError($wrapper); 325 }, 326 complete: function() { 327 self.isLoading = false; 328 } 329 }); 330 }, 331 332 /** 333 * Replace form HTML and initialize - copied from campaign selector. 334 * 335 * @param {string} formHtml The form HTML. 336 * @param {jQuery} $wrapper The wrapper element. 337 * @param {number} campaignId The campaign ID. 338 */ 339 replaceForm: function(formHtml, $wrapper, campaignId) { 340 var self = this; 341 342 debugLog('[Universal Modal] replaceForm() called for campaign ' + campaignId); 343 344 // Extract any inline styles before stripping scripts 345 var inlineStyles = this.extractStyles(formHtml); 346 347 // Strip script tags to prevent DOMEval errors from malformed inline JS 348 var safeHtml = this.stripScripts(formHtml); 349 350 // Replace HTML safely (without executing scripts) 351 $wrapper[0].innerHTML = safeHtml; 352 353 debugLog('[Universal Modal] Form HTML replaced in wrapper'); 354 355 // Refresh window.CHARITABLE_VARS from the per-request vars span the 356 // AJAX handler appends. stripScripts() above removed any <script> 357 // tags, so wp_localize_script output would be lost; values that must 358 // be fresh on every modal open (e.g. math spam-blocker answer) ride 359 // in on this <span class="charitable-ajax-vars"> instead. 360 var $ajaxVars = $wrapper.find('.charitable-ajax-vars').first(); 361 if ($ajaxVars.length) { 362 window.CHARITABLE_VARS = window.CHARITABLE_VARS || {}; 363 var freshMathAnswer = $ajaxVars.attr('data-math-answer'); 364 if (typeof freshMathAnswer !== 'undefined' && freshMathAnswer !== '') { 365 window.CHARITABLE_VARS.math_answer = parseInt(freshMathAnswer, 10); 366 debugLog('[Universal Modal] Refreshed CHARITABLE_VARS.math_answer from AJAX vars: ' + window.CHARITABLE_VARS.math_answer); 367 } 368 $ajaxVars.remove(); 369 } 370 371 // Add any extracted inline styles 372 if (inlineStyles.length) {
373 var $styleContainer = $('<div>'); 374 inlineStyles.forEach(function(css) { 375 $styleContainer.append('<style type="text/css">' + css + '</style>'); 376 }); 377 $wrapper.prepend($styleContainer.children()); 378 } 379 380 // Ensure required CSS/JS assets are loaded 381 this.ensureFormAssets($wrapper); 382 383 // Find the new form element 384 var $newForm = $wrapper.find('#charitable-donation-form, .charitable-donation-form, form[id^="charitable-donation-form"]').first(); 385 386 debugLog('[Universal Modal] Form element found: ' + $newForm.length); 387 debugLog('[Universal Modal] CHARITABLE object: ' + (typeof CHARITABLE !== 'undefined' ? 'EXISTS' : 'MISSING')); 388 debugLog('[Universal Modal] Donation_Form class: ' + (typeof CHARITABLE !== 'undefined' && typeof CHARITABLE.Donation_Form !== 'undefined' ? 'EXISTS' : 'MISSING')); 389 390 // Re-scope the payment gateway radio ids so this modal form's tabs work. 391 // The gateway tabs render with hard-coded ids (id="gateway-<id>") that also 392 // exist in the page's hidden inline copy of the same form. With duplicate ids 393 // a <label for="gateway-<id>"> binds to the FIRST match in the document (the 394 // hidden copy), so clicking a payment tab in the modal toggled the wrong form 395 // and the modal never switched panels. Give this form's gateway radios (and 396 // their labels) a unique token so each label binds to its own radio. 397 // @since 1.8.17 398 if ($newForm.length) { 399 self.gatewayScopeSeq = (self.gatewayScopeSeq || 0) + 1; 400 var gatewayScope = 'modal-' + (campaignId || '0') + '-' + self.gatewayScopeSeq; 401 $newForm.find('input[name="gateway"][id]').each(function() { 402 var $radio = $(this); 403 var oldId = $radio.attr('id'); 404 var newId = oldId + '-' + gatewayScope; 405 $newForm.find('label[for="' + oldId + '"]').attr('for', newId); 406 $radio.attr('id', newId); 407 }); 408 } 409 410 if ($newForm.length && typeof CHARITABLE !== 'undefined') { 411 // Create new Donation_Form instance - this triggers charitable:form:loaded 412 if (typeof CHARITABLE.Donation_Form !== 'undefined') { 413 debugLog('[Universal Modal] Creating new Donation_Form instance'); 414 415 try { 416 var formInstance = new CHARITABLE.Donation_Form($newForm); 417 418 debugLog('[Universal Modal] Donation_Form instance created successfully', formInstance); 419 420 // Verify instance is stored 421 var storedInstance = $newForm.data('charitable-donation-form'); 422 debugLog('[Universal Modal] Form data stored: ' + (storedInstance ? 'YES' : 'NO')); 423 if (!storedInstance) { 424 debugWarn('[Universal Modal] WARNING: Form instance not stored - storing manually'); 425 $newForm.data('charitable-donation-form', formInstance); 426 } 427 428 // Initialize toggles (matches existing modal behavior) 429 if (typeof CHARITABLE !== 'undefined' && typeof CHARITABLE.Toggle === 'function') { 430 CHARITABLE.Toggle(); 431 } 432 433 // Explicitly tell Stripe (and other gateways) to reinit for this replaced form 434 $('body').trigger('charitable:form:replaced', [formInstance]); 435 436 debugLog('[Universal Modal] Form initialization complete - matches existing modal behavior'); 437 438 // Auto-select the quick donate amount in the form. 439 if (self.currentAmount) { 440 var amt = parseFloat(self.currentAmount); 441 var $radios = $newForm.find('input[name="donation_amount"]'); 442 var matched = false;
443 444 $radios.each(function() { 445 if (parseFloat($(this).val()) === amt) { 446 $(this).prop('checked', true).trigger('change'); 447 matched = true; 448 debugLog('[Universal Modal] Auto-selected amount: ' + amt); 449 return false; 450 } 451 }); 452 453 // If no suggested amount matched, fill custom amount. 454 if (!matched) { 455 var $customRadio = $newForm.find('input[name="donation_amount"][value="custom"]'); 456 var $customInput = $newForm.find('.custom-donation-input'); 457 if ($customRadio.length && $customInput.length) { 458 $customRadio.prop('checked', true).trigger('change'); 459 $customInput.val(amt); 460 debugLog('[Universal Modal] Set custom amount: ' + amt); 461 } 462 } 463 } 464 465 } catch (e) { 466 debugError('[Universal Modal] Error creating Donation_Form instance:', e); 467 } 468 } else { 469 debugWarn('[Universal Modal] CHARITABLE.Donation_Form not available'); 470 } 471 } else { 472 debugWarn('[Universal Modal] Form element or CHARITABLE object not found'); 473 } 474 475 // Handle campaign selector: hide if trigger requested it, otherwise load assets. 476 if ($wrapper.find('.charitable-campaign-selector-wrapper').length > 0) { 477 if (this.hideCampaignSelector) { 478 debugLog('[Universal Modal] Hiding campaign selector per trigger override'); 479 $wrapper.find('.charitable-campaign-selector-wrapper').hide(); 480 } else { 481 debugLog('[Universal Modal] Campaign selector detected - triggering asset loading'); 482 $(document).trigger('charitable:campaign:selector:loaded', [$wrapper]); 483 } 484 } 485 486 // Trigger custom event for extensibility 487 $(document).trigger('charitable:modal:form:loaded', [$wrapper, campaignId]); 488 }, 489 490 /** 491 * Show error message. 492 * 493 * @param {jQuery} $wrapper The wrapper element. 494 */ 495 showError: function($wrapper) { 496 var errorHtml = '<div class="charitable-modal-error">' + 497 '<p><strong>Error:</strong> Unable to load donation form. Please try again.</p>' + 498 '</div>'; 499 500 $wrapper.html(errorHtml); 501 }, 502 503 /** 504 * Strip script tags from HTML - copied from campaign selector. 505 * 506 * @param {string} html Raw HTML string. 507 * @return {string} HTML with scripts removed. 508 */ 509 stripScripts: function(html) { 510 // Remove all script tags and their contents 511 return html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ''); 512 }, 513 514 /** 515 * Extract inline styles from HTML - copied from campaign selector. 516 * 517 * @param {string} html Raw HTML string. 518 * @return {Array} Array of style contents. 519 */ 520 extractStyles: function(html) { 521 var styles = []; 522 var styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi; 523 var match; 524 525 while ((match = styleRegex.exec(html)) !== null) { 526 if (match[1] && match[1].trim().length > 0) { 527 styles.push(match[1]); 528 } 529 } 530 531 return styles; 532 }, 533 534 /** 535 * Ensure form assets are loaded - simplified version of campaign selector. 536 * 537 * @param {jQuery} $wrapper The wrapper element. 538 */ 539 ensureFormAssets: function($wrapper) { 540 var self = this; 541
542 // Check for visual form CSS needs 543 var $visualForm = $wrapper.find('.charitable-donation-form'); 544 if ($visualForm.length && !this.loadedCss['charitable-donation-form']) { 545 // Visual form detected - CSS should already be loaded by WordPress 546 // but we can trigger an event for additional asset loading if needed 547 $(document).trigger('charitable:modal:visual:form:detected', [$wrapper]); 548 this.loadedCss['charitable-donation-form'] = true; 549 } 550 551 // Check for legacy form 552 var $legacyForm = $wrapper.find('#charitable-donation-form'); 553 if ($legacyForm.length) { 554 $(document).trigger('charitable:modal:legacy:form:detected', [$wrapper]); 555 } 556 } 557 558 }; 559 560 // Initialize when document is ready 561 $(document).ready(function() { 562 CharitableUniversalModal.init(); 563 }); 564 565 // Expose to global scope for extensibility 566 window.CharitableUniversalModal = CharitableUniversalModal; 567 568})(jQuery);
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.