PageSourceSearch

https://cityofraleighmuseum.org/wp-content/plugins/charitable-pro/…/charitable-universal-modal.js?ver=1.8.17.1

js cityofraleighmuseum.org collected 2026-10-02 12:11:44 UTC 24,431 bytes, 568 lines download raw bytes

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.