PageSourceSearch

https://www.thehalfwayinnwareham.co.uk/js/silktide-consent-manager-2.0.1.js

js thehalfwayinnwareham.co.uk collected 2026-09-26 19:41:55 UTC 53,701 bytes, 1,525 lines download raw bytes

1'use strict';
2
3/**
4 * Silktide Consent Manager v2.0
5 * https://silktide.com/consent-manager/
6 * This class is typically not instantiated directly. Use the global
7 * window.silktideConsentManager API below.
8 *
9 * @class
10 * @param {Object} config - Configuration object for the consent manager
11 */
12class SilktideConsentManager {
13  constructor(config) {
14    this._validateConfig(config);
15    this.config = config;
16
17    // Set default eventName if not provided
18    this.config.eventName = this.config.eventName || 'stcm_consent_update';
19
20    // Set default debug mode (false = no console logs in production)
21    // Ensure debug is a boolean, default to false
22    this.config.debug = this.config.debug === true;
23
24    this.wrapper = null;
25    this.prompt = null;
26    this.preferences = null;
27    this.icon = null;
28    this.backdrop = null;
29    this.localStorageAvailable = this._checkLocalStorageAvailable();
30    this._needsReload = false;
31
32    this.createWrapper();
33
34    if (this.shouldShowBackdrop()) {
35      this.createBackdrop();
36    }
37
38    this.createCookieIcon();
39    this.createModal();
40
41    if (this.shouldShowPrompt()) {
42      this.createBanner();
43      this.showBackdrop();
44    } else {
45      this.showCookieIcon();
46    }
47
48    this.setupEventListeners();
49
50    // Always run consent callbacks on load (handles required consents even on first visit)
51    this.runConsentCallbacksOnLoad();
52  }
53
54  /**
55   * Validate configuration object
56   * @private
57   * @param {Object} config - Configuration to validate
58   * @throws {Error} If config is invalid
59   */
60  _validateConfig(config) {
61    if (!config) {
62      throw new Error('Silktide Consent Manager: config is required');
63    }
64    if (!config.consentTypes || !Array.isArray(config.consentTypes)) {
65      throw new Error('Silktide Consent Manager: config.consentTypes must be an array');
66    }
67    if (config.consentTypes.length === 0) {
68      throw new Error('Silktide Consent Manager: config.consentTypes cannot be empty');
69    }
70  }
71
72  /**
73   * Check if localStorage is available and accessible
74   * @private
75   * @returns {boolean} True if localStorage can be used
76   */
77  _checkLocalStorageAvailable() {
78    try {
79      const testKey = '__stcm_test__';
80      localStorage.setItem(testKey, '1');
81      localStorage.removeItem(testKey);
82      return true;
83    } catch (e) {
84      console.warn('Silktide Consent Manager: localStorage is not available. Consent choices will not persist.', e);
85      return false;
86    }
87  }
88
89  /**
90   * Safely get item from localStorage with error handling
91   * @private
92   * @param {string} key - localStorage key
93   * @returns {string|null} Value from localStorage or null
94   */
95  _getLocalStorageItem(key) {
96    if (!this.localStorageAvailable) {
97      return null;
98    }
99    try {
100      return localStorage.getItem(key);
101    } catch (e) {
102      console.warn('Silktide Consent Manager: Error reading from localStorage', e);
103      return null;
104    }
105  }
106
107  /**
108   * Safely set item in localStorage with error handling
109   * @private
110   * @param {string} key - localStorage key
111   * @param {string} value - Value to store
112   * @returns {boolean} True if successful
113   */
114  _setLocalStorageItem(key, value) {
115    if (!this.localStorageAvailable) {
116      return false;
117    }
118    try {
119      localStorage.setItem(key, value);
120      return true;
121    } catch (e) {
122      console.warn('Silktide Consent Manager: Error writing to localStorage', e);
123      return false;
124    }
125  }
126
127  /**
128   * Safely remove item from localStorage with error handling
129   * @private
130   * @param {string} key - localStorage key
131   * @returns {boolean} True if successful
132   */
133  _removeLocalStorageItem(key) {
134    if (!this.localStorageAvailable) {
135      return false;
136    }
137    try {
138      localStorage.removeItem(key);
139      return true;
140    } catch (e) {
141      console.warn('Silktide Consent Manager: Error removing from localStorage', e);
142      return false;
143    }
144  }
145
146  /**
147   * Clean up and remove all consent manager elements from the DOM
148   */
149  destroy() {
150    // Remove all consent manager elements from the DOM
151    if (this.wrapper && this.wrapper.parentNode) {
152      this.wrapper.parentNode.removeChild(this.wrapper);
153    }
154
155    // Restore scrolling
156    this.allowBodyScroll();
157
158    // Clear all references
159    this.wrapper = null;
160    this.prompt = null;
161    this.preferences = null;
162    this.icon = null;
163    this.backdrop = null;
164  }
165
166  /**
167   * Clear all consent choices from localStorage
168   * Clears both old and new key patterns for this namespace
169   */
170  clearAllConsents() {
171    if (!this.localStorageAvailable) {
172      return;
173    }
174
175    const keysToRemove = [];
176
177    try {
178      for (let i = 0; i < localStorage.length; i++) {
179        const key = localStorage.key(i);
180        // Match both old and new key patterns
181        if (key && (
182          key.startsWith('silktideCookieBanner_') ||
183          key.startsWith('silktideCookieChoice_') ||
184          key.startsWith('stcm.')
185        )) {
186          keysToRemove.push(key);
187        }
188      }
189
190      keysToRemove.forEach(key => this._removeLocalStorageItem(key));
191    } catch (e) {
192      // Ignore errors
193    }
194  }
195
196  // ----------------------------------------------------------------
197  // localStorage Helper Methods (with lazy migration)
198  // ----------------------------------------------------------------
199
200  // Old key builders (for migration only)
201  _buildOldConsentKey(typeId) {
202    const suffix = this.config.namespace ? `_${this.config.namespace}` : '';
203    return `silktideCookieChoice_${typeId}${suffix}`;
204  }
205
206  _buildOldHasConsentedKey() {
207    const suffix = this.config.namespace ? `_${this.config.namespace}` : '';
208    return `silktideCookieBanner_InitialChoice${suffix}`;
209  }
210
211  // New key builders
212  _buildConsentKey(typeId) {
213    const ns = this.config.namespace ? `${this.config.namespace}.` : '';
214    return `stcm.${ns}consent.${typeId}`;
215  }
216
217  _buildHasConsentedKey() {
218    const ns = this.config.namespace ? `${this.config.namespace}.` : '';
219    return `stcm.${ns}hasConsented`;
220  }
221
222  /**
223   * Get the consent choice for a specific consent type
224   * @param {string} typeId - The consent type ID
225   * @returns {boolean|null} True if accepted, false if rejected, null if no choice made
226   */
227  getConsentChoice(typeId) {
228    const newKey = this._buildConsentKey(typeId);
229    let value = this._getLocalStorageItem(newKey);
230
231    // Migration: Check old key if new key doesn't exist
232    if (value === null) {
233      const oldKey = this._buildOldConsentKey(typeId);
234      value = this._getLocalStorageItem(oldKey);
235
236      if (value !== null) {
237        // Migrate: write to new key, delete old key
238        this._setLocalStorageItem(newKey, value);
239        this._removeLocalStorageItem(oldKey);
240      }
241    }
242
243    // Return null if no value exists, otherwise return boolean
244    return value === null ? null : value === 'true';
245  }
246
247  /**
248   * Set the consent choice for a specific consent type
249   * @param {string} typeId - The consent type ID
250   * @param {boolean} accepted - Whether the consent is accepted
251   */
252  setConsentChoice(typeId, accepted) {
253    const newKey = this._buildConsentKey(typeId);
254    this._setLocalStorageItem(newKey, accepted.toString());
255
256    // Cleanup: remove old key if it exists
257    const oldKey = this._buildOldConsentKey(typeId);
258    if (this._getLocalStorageItem(oldKey) !== null) {
259      this._removeLocalStorageItem(oldKey);
260    }
261  }
262
263  /**
264   * Check if user has made an initial consent choice
265   * @returns {boolean} True if user has consented
266   */
267  getHasConsented() {
268    const newKey = this._buildHasConsentedKey();
269    let value = this._getLocalStorageItem(newKey);
270
271    // Migration: Check old key if new key doesn't exist
272    if (value === null) {
273      const oldKey = this._buildOldHasConsentedKey();
274      value = this._getLocalStorageItem(oldKey);
275
276      if (value !== null) {
277        this._setLocalStorageItem(newKey, value);
278        this._removeLocalStorageItem(oldKey);
279      }
280    }
281
282    return value !== null;
283  }
284
285  setHasConsented() {
286    const newKey = this._buildHasConsentedKey();
287    this._setLocalStorageItem(newKey, '1');
288
289    // Cleanup old key
290    const oldKey = this._buildOldHasConsentedKey();
291    if (this._getLocalStorageItem(oldKey) !== null) {
292      this._removeLocalStorageItem(oldKey);
293    }
294  }
295
296  // ----------------------------------------------------------------
297  // Script Injection
298  // ----------------------------------------------------------------
299
300  /**
301   * Inject a script element with specified attributes
302   * @private
303   * @param {Object} scriptConfig - Script configuration
304   * @param {string} consentId - Consent type ID (for tracking)
305   */
306  _injectScript(scriptConfig, consentId) {
307    const { url, load, type, crossorigin, integrity } = scriptConfig;
308
309    if (!url) {
310      console.warn('Silktide Consent Manager: Script URL is required', scriptConfig);
311      return;
312    }
313
314    // Check if script already exists
315    const existingScript = document.querySelector(`script[src="${url}"]`);
316    if (existingScript) {
317      return; // Already injected
318    }
319
320    const script = document.createElement('script');
321    script.src = url;
322    script.dataset.consentId = consentId; // Track which consent type added this
323
324    if (load === 'async') script.async = true;
325    if (load === 'defer') script.defer = true;
326    if (type) script.type = type;
327    if (crossorigin) script.crossOrigin = crossorigin;
328    if (integrity) script.integrity = integrity;
329
330    document.head.appendChild(script);
331  }
332
333  /**
334   * Inject all scripts for a consent type
335   * @private
336   * @param {Object} consentType - Consent type configuration
337   */
338  _injectConsentScripts(consentType) {
339    if (!consentType.scripts || !Array.isArray(consentType.scripts)) {
340      return;
341    }
342
343    consentType.scripts.forEach(scriptConfig => {
344      this._injectScript(scriptConfig, consentType.id);
345    });
346  }
347
348  /**
349   * Check if consent was revoked (changed from accepted to rejected)
350   * @private
351   * @param {string} consentId - Consent type ID
352   * @param {boolean} newState - New consent state
353   * @returns {boolean} True if consent was revoked
354   */
355  _wasConsentRevoked(consentId, newState) {
356    const previousState = this.getConsentChoice(consentId);
357    // Consent was revoked if: previously true, now false
358    return previousState === true && newState === false;
359  }
360
361  // ----------------------------------------------------------------
362  // Wrapper
363  // ----------------------------------------------------------------
364  createWrapper() {
365    this.wrapper = document.createElement('div');
366    this.wrapper.id = 'stcm-wrapper';
367    document.body.insertBefore(this.wrapper, document.body.firstChild);
368  }
369
370  // ----------------------------------------------------------------
371  // Wrapper Child Generator
372  // ----------------------------------------------------------------
373  createWrapperChild(htmlContent, id) {
374    // Create child element
375    const child = document.createElement('div');
376    child.id = id;
377    child.innerHTML = htmlContent;
378
379    // Ensure wrapper exists
380    if (!this.wrapper || !document.body.contains(this.wrapper)) {
381      this.createWrapper();
382    }
383
384    // Append child to wrapper
385    this.wrapper.appendChild(child);
386    return child;
387  }
388
389  // ----------------------------------------------------------------
390  // Backdrop
391  // ----------------------------------------------------------------
392  createBackdrop() {
393    this.backdrop = this.createWrapperChild(null, 'stcm-backdrop');
394
395    // Add click handler to nudge banner/modal when backdrop is clicked
396    this.backdrop.addEventListener('click', () => {
397      this.nudgePrompt();
398    });
399  }
400
401  showBackdrop() {
402    if (this.backdrop) {
403      this.backdrop.style.display = 'block';
404    }
405    // Trigger optional onBackdropOpen callback
406    if (typeof this.config.onBackdropOpen === 'function') {
407      this.config.onBackdropOpen();
408    }
409  }
410
411  hideBackdrop() {
412    if (this.backdrop) {
413      this.backdrop.style.display = 'none';
414    }
415
416    // Trigger optional onBackdropClose callback
417    if (typeof this.config.onBackdropClose === 'function') {
418      this.config.onBackdropClose();
419    }
420  }
421
422  /**
423   * Check if backdrop should be shown
424   * @private
425   * @returns {boolean} True if backdrop should be shown
426   */
427  shouldShowBackdrop() {
428    return this.config?.backdrop?.show || false;
429  }
430
431  /**
432   * Nudge the consent prompt to draw attention when backdrop is clicked
433   *
434   * Note: This deliberately only nudges the prompt, not the modal.
435   * The prompt is the initial banner that requires immediate action.
436   * Nudging the modal would be disruptive to users reviewing preferences.
437   *
438   * @private
439   */
440  nudgePrompt() {
441    if (!this.prompt) {
442      return;
443    }
444
445    // Remove class if it exists
446    this.prompt.classList.remove('stcm-nudge');
447
448    // Force reflow to restart animation
449    void this.prompt.offsetWidth;
450
451    // Add class to trigger animation
452    this.prompt.classList.add('stcm-nudge');
453
454    // Remove class when animation completes (CSS defines duration)
455    this.prompt.addEventListener('animationend', () => {
456      if (this.prompt) {
457        this.prompt.classList.remove('stcm-nudge');
458      }
459    }, { once: true });
460  }
461
462  // update the checkboxes in the preferences with the values from localStorage
463  updateCheckboxState(saveToStorage = false) {
464    const preferencesSection = this.preferences.querySelector('#stcm-form');
465    const checkboxes = preferencesSection.querySelectorAll('input[type="checkbox"]');
466
467    checkboxes.forEach((checkbox) => {
468      const [, consentId] = checkbox.id.split('consent-');
469      const consentType = this.config.consentTypes.find(type => type.id === consentId);
470
471      if (!consentType) return;
472
473      if (saveToStorage) {
474        // Save the current state to localStorage and run callbacks
475        const currentState = checkbox.checked;
476
477        if (consentType.required) {
478          this.setConsentChoice(consentId, true);
479        } else {
480          // Check if the value actually changed
481          const previousValue = this.getConsentChoice(consentId);
482
483          // Check if consent was revoked (and had scripts)
484          const wasRevoked = this._wasConsentRevoked(consentId, currentState);
485          const hadScripts = consentType.scripts?.length > 0;
486
487          this.setConsentChoice(consentId, currentState);
488
489          // Only trigger integration and callbacks if value changed
490          // Note: previousValue can be:
491          //   - null: User never made a choice (using default)
492          //   - true: User previously accepted
493          //   - false: User previously rejected
494          // We fire callbacks even when changing from null to false/true
495          // because it represents an explicit choice being made
496          if (currentState !== previousValue) {
497            // Trigger automatic consent integration
498            this.triggerConsentIntegration(consentType, currentState);
499
500            // Run appropriate callback
501            if (currentState && typeof consentType.onAccept === 'function') {
502              consentType.onAccept();
503            } else if (!currentState && typeof consentType.onReject === 'function') {
504              consentType.onReject();
505            }
506          }
507
508          // Track if any consent was revoked for page reload
509          if (wasRevoked && hadScripts) {
510            this._needsReload = true;
511          }
512        }
513      } else {
514        // When reading values (opening preferences)
515        if (consentType.required) {
516          checkbox.checked = true;
517          checkbox.disabled = true;
518        } else {
519          const storedValue = this.getConsentChoice(consentId);
520
521          if (storedValue !== null) {
522            checkbox.checked = storedValue;
523          } else {
524            checkbox.checked = !!consentType.defaultValue;
525          }
526        }
527      }
528    });
529  }
530
531  /**
532   * Trigger GTM consent updates based on consentType.gtag property
533   */
534  triggerConsentIntegration(consentType, accepted) {
535    // Check if gtag parameter(s) are specified
536    if (!consentType.gtag) {
537      return;
538    }
539
540    // Normalize to array for consistent handling
541    const gtagParams = Array.isArray(consentType.gtag) ? consentType.gtag : [consentType.gtag];
542
543    // If GTM exists, call gtag
544    if (typeof gtag === 'function') {
545      const consentState = accepted ? 'granted' : 'denied';
546      const consentUpdate = {};
547
548      gtagParams.forEach(param => {
549        consentUpdate[param] = consentState;
550      });
551
552      gtag('consent', 'update', consentUpdate);
553    } else if (gtagParams.includes('analytics_storage')) {
554      // Otherwise, if Silktide Analytics exists, fire consent event
555      if (typeof window.silktide === 'function') {
556        window.silktide(accepted ? 'consent' : 'unconsent');
557      }
558    }
559
560    // Push generic consent update event to dataLayer for GTM tag triggers
561    window.dataLayer = window.dataLayer || [];
562    window.dataLayer.push({ 'event': this.config.eventName });
563  }
564
565  /**
566   * Batch update all consents at once
567   * Compares current states against localStorage and only triggers updates if changes detected
568   * @param {Object} consentStates - Object mapping consent type IDs to boolean values
569   * @returns {boolean} - True if any changes were made
570   */
571  batchUpdateConsents(consentStates) {
572    const changes = [];
573    const gtagConsentUpdate = {};
574    let hasChanges = false;
575    let needsReload = false;
576
577    // First pass: identify changes and build gtag consent object
578    this.config.consentTypes.forEach((type) => {
579      const newState = consentStates[type.id];
580      const previousState = this.getConsentChoice(type.id);
581
582      // Check if this consent actually changed
583      if (newState !== previousState) {
584        hasChanges = true;
585
586        changes.push({
587          type: type,
588          newState: newState,
589          previousState: previousState
590        });
591
592        // Check if consent was revoked and had scripts
593        const wasRevoked = previousState === true && newState === false;
594        const hadScripts = type.scripts?.length > 0;
595        if (wasRevoked && hadScripts) {
596          needsReload = true;
597        }
598
599        // Build gtag consent parameters
600        if (type.gtag) {
601          const gtagParams = Array.isArray(type.gtag) ? type.gtag : [type.gtag];
602          const consentState = newState ? 'granted' : 'denied';
603          gtagParams.forEach(param => {
604            gtagConsentUpdate[param] = consentState;
605          });
606        }
607      }
608    });
609
610    // If no changes, return early
611    if (!hasChanges) {
612      return false;
613    }
614
615    // Second pass: save to localStorage
616    changes.forEach(({ type, newState }) => {
617      this.setConsentChoice(type.id, newState);
618    });
619
620    // Call gtag once with all consent updates
621    if (Object.keys(gtagConsentUpdate).length > 0 && typeof gtag === 'function') {
622      gtag('consent', 'update', gtagConsentUpdate);
623      if (this.config.debug) {
624        console.log('✓ gtag consent updated (from user action):', gtagConsentUpdate);
625      }
626    } else if (gtagConsentUpdate.analytics_storage) {
627      // Silktide Analytics fallback
628      if (typeof window.silktide === 'function') {
629        const analyticsGranted = gtagConsentUpdate.analytics_storage === 'granted';
630        window.silktide(analyticsGranted ? 'consent' : 'unconsent');
631      }
632    }
633
634    // Fire single GTM event
635    window.dataLayer = window.dataLayer || [];
636    window.dataLayer.push({ 'event': this.config.eventName });
637    if (this.config.debug) {
638      console.log('▶ GTM Event Sent: ' + this.config.eventName + ' (from user action)');
639    }
640
641    // Third pass: run callbacks and inject scripts
642    changes.forEach(({ type, newState }) => {
643      if (newState) {
644        this._injectConsentScripts(type);
645        if (typeof type.onAccept === 'function') {
646          type.onAccept();
647        }
648      } else {
649        if (typeof type.onReject === 'function') {
650          type.onReject();
651        }
652      }
653    });
654
655    // Reload page if consent was revoked and scripts were injected
656    if (needsReload) {
657      setTimeout(() => {
658        window.location.reload();
659      }, 100);
660    }
661
662    return true;
663  }
664
665  // ----------------------------------------------------------------
666  // Consent Handling
667  // ----------------------------------------------------------------
668  handleConsentChoice(accepted) {
669    // We set that an initial choice was made regardless of what it was so we don't show the prompt again
670    this.setHasConsented();
671
672    this.removeBanner();
673    this.hideBackdrop();
674    this.toggleModal(false);
675    this.showCookieIcon();
676
677    // Build consent states object for all consent types
678    const consentStates = {};
679    this.config.consentTypes.forEach((type) => {
680      if (type.required) {
681        consentStates[type.id] = true;
682      } else {
683        consentStates[type.id] = accepted;
684      }
685    });
686
687    // Use batch update to set all consents at once
688    this.batchUpdateConsents(consentStates);
689
690    // Trigger optional onAcceptAll/onRejectAll callbacks
691    if (accepted && typeof this.config.onAcceptAll === 'function') {
692      this.config.onAcceptAll();
693    } else if (!accepted && typeof this.config.onRejectAll === 'function') {
694      this.config.onRejectAll();
695    }
696
697    // finally update the checkboxes in the modal with the values from localStorage
698    this.updateCheckboxState();
699  }
700
701  /**
702   * Get all accepted consents as an object
703   * @returns {Object} Object mapping consent type IDs to their accepted state
704   */
705  getAcceptedConsents() {
706    return (this.config.consentTypes || []).reduce((acc, consentType) => {
707      acc[consentType.id] = this.getConsentChoice(consentType.id);
708      return acc;
709    }, {});
710  }
711
712  /**
713   * Get all rejected consents as an object
714   * @returns {Object} Object mapping consent type IDs to their rejected state
715   */
716  getRejectedConsents() {
717    return (this.config.consentTypes || []).reduce((acc, consentType) => {
718      const choice = this.getConsentChoice(consentType.id);
719      // Only return true if explicitly rejected (false), not if no choice made (null)
720      acc[consentType.id] = choice === false;
721      return acc;
722    }, {});
723  }
724
725  /**
726   * Run all consent callbacks on page load
727   * Builds a single gtag consent object with both accepted and rejected consents
728   * Fires one GTM event only if this is the first consent load
729   */
730  runConsentCallbacksOnLoad() {
731    if (!this.config.consentTypes) return;
732
733    // Build a single gtag consent object with both accepted and rejected consents
734    const gtagConsentUpdate = {};
735    let hasGtagUpdates = false;
736    let isFirstConsentLoad = false;
737
738    const acceptedConsents = this.getAcceptedConsents();
739    const rejectedConsents = this.getRejectedConsents();
740
741    // Process all consent types and build one comprehensive gtag object
742    this.config.consentTypes.forEach((type) => {
743      // Handle required consents (always inject scripts and run onAccept)
744      if (type.required) {
745        // Set to localStorage immediately if not already set (prevents duplicate firing)
746        const currentValue = this.getConsentChoice(type.id);
747        if (currentValue === null) {
748          this.setConsentChoice(type.id, true);
749          isFirstConsentLoad = true; // Required consent was just set for first time
750        }
751
752        this._injectConsentScripts(type);
753
754        // Add required consents to gtag update (always granted)
755        if (type.gtag) {
756          hasGtagUpdates = true;
757          const gtagParams = Array.isArray(type.gtag) ? type.gtag : [type.gtag];
758          gtagParams.forEach(param => {
759            gtagConsentUpdate[param] = 'granted';
760          });
761        }
762
763        if (typeof type.onAccept === 'function') {
764          type.onAccept();
765        }
766        return;
767      }
768
769      // Check if accepted
770      if (acceptedConsents[type.id]) {
771        this._injectConsentScripts(type);
772
773        if (type.gtag) {
774          hasGtagUpdates = true;
775          const gtagParams = Array.isArray(type.gtag) ? type.gtag : [type.gtag];
776          gtagParams.forEach(param => {
777            gtagConsentUpdate[param] = 'granted';
778          });
779        }
780
781        if (typeof type.onAccept === 'function') {
782          type.onAccept();
783        }
784      }
785      // Check if rejected
786      else if (rejectedConsents[type.id]) {
787        if (type.gtag) {
788          hasGtagUpdates = true;
789          const gtagParams = Array.isArray(type.gtag) ? type.gtag : [type.gtag];
790          gtagParams.forEach(param => {
791            gtagConsentUpdate[param] = 'denied';
792          });
793        }
794
795        if (typeof type.onReject === 'function') {
796          type.onReject();
797        }
798      }
799    });
800
801    // Call gtag ONCE with all consent states (both granted and denied)
802    if (hasGtagUpdates && typeof gtag === 'function') {
803      gtag('consent', 'update', gtagConsentUpdate);
804      if (this.config.debug) {
805        console.log('✓ gtag consent updated (on page load):', gtagConsentUpdate);
806      }
807    } else if (gtagConsentUpdate.analytics_storage && typeof window.silktide === 'function') {
808      // Silktide Analytics fallback
809      const analyticsGranted = gtagConsentUpdate.analytics_storage === 'granted';
810      window.silktide(analyticsGranted ? 'consent' : 'unconsent');
811    }
812
813    // Fire GTM event if we have any granted consents (so GTM tags can trigger)
814    // Check if any consent is granted (not just denied)
815    const hasGrantedConsents = Object.values(gtagConsentUpdate).some(value => value === 'granted');
816    if (hasGtagUpdates && hasGrantedConsents) {
817      window.dataLayer = window.dataLayer || [];
818      window.dataLayer.push({ 'event': this.config.eventName });
819      if (this.config.debug) {
820        const eventContext = isFirstConsentLoad ? 'from first page load' : 'from return visit';
821        console.log('▶ GTM Event Sent: ' + this.config.eventName + ' (' + eventContext + ')');
822      }
823    }
824  }
825
826  /**
827   * Run through all of the consent callbacks based on the current localStorage values
828   */
829  runStoredConsentCallbacks() {
830    this.config.consentTypes.forEach((type) => {
831      const accepted = this.getConsentChoice(type.id);
832
833      if (accepted) {
834        this._injectConsentScripts(type);
835      }
836
837      // Trigger automatic consent integration
838      this.triggerConsentIntegration(type, accepted);
839
840      // Set localStorage and run accept/reject callbacks
841      if (accepted) {
842        if (typeof type.onAccept === 'function') {
843          type.onAccept();
844        }
845      } else {
846        if (typeof type.onReject === 'function') {
847          type.onReject();
848        }
849      }
850    });
851  }
852
853  // ----------------------------------------------------------------
854  // Banner
855  // ----------------------------------------------------------------
856
857  /**
858   * Get banner HTML content
859   * @private
860   * @returns {string} HTML string for banner
861   */
862  getBannerContent() {
863    const bannerDescription =
864      this.config.text?.prompt?.description ||
865      "<p>We use cookies on our site to enhance your user experience, provide personalized content, and analyze our traffic.</p>";
866
867    // Accept button
868    const acceptAllButtonText = this.config.text?.prompt?.acceptAllButtonText || 'Accept all';
869    const acceptAllButtonLabel = this.config.text?.prompt?.acceptAllButtonAccessibleLabel;
870    const acceptAllButton = `<button class="stcm-accept-all stcm-button stcm-button-primary"${
871      acceptAllButtonLabel && acceptAllButtonLabel !== acceptAllButtonText
872        ? ` aria-label="${acceptAllButtonLabel}"`
873        : ''
874    }>${acceptAllButtonText}</button>`;
875
876    // Reject button
877    const rejectNonEssentialButtonText = this.config.text?.prompt?.rejectNonEssentialButtonText || 'Reject non-essential';
878    const rejectNonEssentialButtonLabel = this.config.text?.prompt?.rejectNonEssentialButtonAccessibleLabel;
879    const rejectNonEssentialButton = `<button class="stcm-reject-all stcm-button stcm-button-primary"${
880      rejectNonEssentialButtonLabel && rejectNonEssentialButtonLabel !== rejectNonEssentialButtonText
881        ? ` aria-label="${rejectNonEssentialButtonLabel}"`
882        : ''
883    }>${rejectNonEssentialButtonText}</button>`;
884
885    // Preferences button
886    const preferencesButtonText = this.config.text?.prompt?.preferencesButtonText || 'Preferences';
887    const preferencesButtonLabel = this.config.text?.prompt?.preferencesButtonAccessibleLabel;
888    const preferencesButton = `<button class="stcm-preferences-button"${
889      preferencesButtonLabel && preferencesButtonLabel !== preferencesButtonText
890        ? ` aria-label="${preferencesButtonLabel}"`
891        : ''
892    }><span>${preferencesButtonText}</span></button>`;
893
894
895    // Silktide logo link
896    const silktideLogo = `
897      <a class="stcm-logo" href="https://silktide.com/consent-manager" rel="noreferrer" aria-label="Visit the Silktide Consent Manager page">
898        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="25" viewB
898ox="0 0 24 25" fill="inherit">
899          <path fill-rule="evenodd" clip-rule="evenodd" d="M14.1096 16.7745C13.8895 17.2055 13.3537 17.3805 12.9129 17.1653L8.28443 14.9055L2.73192 17.7651L11.1025 21.9814C11.909 22.3876 12.8725 22.3591 13.6524 21.9058L20.4345 17.9645C21.2845 17.4704 21.7797 16.5522 21.7164 15.5872L21.7088 15.4704C21.6487 14.5561 21.0962 13.7419 20.2579 13.3326L15.6793 11.0972L10.2283 13.9045L13.71 15.6043C14.1507 15.8195 14.3297 16.3434 14.1096 16.7745ZM8.2627 12.9448L13.7136 10.1375L10.2889 8.46543C9.84803 8.25021 9.66911 7.72629 9.88916 7.29524C10.1093 6.86417 10.6451 6.68921 11.0859 6.90442L15.6575 9.13647L21.2171 6.27325L12.8808 2.03496C12.0675 1.62147 11.0928 1.65154 10.3078 2.11432L3.54908 6.09869C2.70732 6.59492 2.21846 7.50845 2.28139 8.46761L2.29003 8.59923C2.35002 9.51362 2.9026 10.3278 3.7409 10.7371L8.2627 12.9448ZM6.31884 13.9458L2.94386 12.2981C1.53727 11.6113 0.610092 10.2451 0.509431 8.71094L0.500795 8.57933C0.3952 6.96993 1.21547 5.4371 2.62787 4.60447L9.38662 0.620092C10.7038 -0.156419 12.3392 -0.206861 13.7039 0.486938L23.3799 5.40639C23.4551 5.44459 23.5224 5.4918 23.5811 5.54596C23.7105 5.62499 23.8209 5.73754 23.897 5.87906C24.1266 6.30534 23.9594 6.83293 23.5234 7.05744L17.6231 10.0961L21.0549 11.7716C22.4615 12.4583 23.3887 13.8245 23.4893 15.3587L23.497 15.4755C23.6033 17.0947 22.7724 18.6354 21.346 19.4644L14.5639 23.4057C13.2554 24.1661 11.6386 24.214 10.2854 23.5324L0.621855 18.6649C0.477299 18.592 0.361696 18.4859 0.279794 18.361C0.210188 18.2968 0.150054 18.2204 0.10296 18.133C-0.126635 17.7067 0.0406445 17.1792 0.47659 16.9546L6.31884 13.9458Z" fill="inherit"/>
900        </svg>
901      </a>
902    `;
903
904    const bannerContent = `
905      ${bannerDescription}
906      <div class="stcm-actions">
907        ${acceptAllButton}
908        ${rejectNonEssentialButton}
909        <div class="stcm-actions-row">
910          ${preferencesButton}
911          ${silktideLogo}
912        </div>
913      </div>
914    `;
915
916    return bannerContent;
917  }
918
919  /**
920   * Check if user has made an initial consent choice
921   * @returns {boolean} True if user has consented
922   */
923  hasConsented() {
924    return this.getHasConsented();
925  }
926
927  createBanner() {
928    // Create prompt element
929    this.prompt = this.createWrapperChild(this.getBannerContent(), 'stcm-banner');
930
931    // Add positioning class from config
932    if (this.prompt && this.config.prompt?.position) {
933      // Map old position names to new stcm- prefixed names
934      const positionMap = {
935        'center': 'stcm-pos-center',
936        'bottomLeft': 'stcm-pos-bottom-left',
937        'bottomCenter': 'stcm-pos-bottom-center',
938        'bottomRight': 'stcm-pos-bottom-right'
939      };
940      const mappedPosition = positionMap[this.config.prompt.position] || this.config.prompt.position;
941      this.prompt.classList.add(mappedPosition);
942    }
943
944    // Lock in final state after slide-in animation completes
945    // This allows nudge animation to work without conflicts
946    this.prompt.addEventListener('animationend', () => {
947      if (this.prompt) {
948        this.prompt.classList.add('stcm-loaded');
949      }
950    }, { once: true });
951
952    // Trigger optional onPromptOpen callback
953    if (this.prompt && typeof this.config.onPromptOpen === 'function') {
954      this.config.onPromptOpen();
955    }
956  }
957
958  removeBanner() {
959    if (this.prompt && this.prompt.parentNode) {
960      this.prompt.parentNode.removeChild(this.prompt);
961      this.prompt = null;
962
963      // Trigger optional onPromptClose callback
964      if (typeof this.config.onPromptClose === 'function') {
965        this.config.onPromptClose();
966      }
967    }
968  }
969
970  /**
971   * Check if consent prompt should be shown
972   * Returns true if user hasn't made a consent choice yet
973   * @private
974   * @returns {boolean} True if prompt should be displayed
975   */
976  shouldShowPrompt() {
977    if (this.config.autoShow === false) {
978      return false;
979    }
980    return !this.getHasConsented();
981  }
982
983  // ----------------------------------------------------------------
984  // Modal
985  // ----------------------------------------------------------------
986
987  /**
988   * Get modal HTML content
989   * @private
990   * @returns {string} HTML string for modal
991   */
992  getModalContent() {
993    const preferencesTitle =
994      this.config.text?.preferences?.title || 'Customize your cookie preferences';
995
996    const preferencesDescription =
997      this.config.text?.preferences?.description ||
998      "<p>We respect your right to privacy. You can choose not to allow some types of cookies. Your cookie preferences will apply across our website.</p>";
999
1000    // Preferences button
1001    const preferencesButtonLabel = this.config.text?.prompt?.preferencesButtonAccessibleLabel;
1002
1003    const closeModalButton = `<button class="stcm-modal-close"${preferencesButtonLabel ? ` aria-label="${preferencesButtonLabel}"` : ''}>
1004      <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
1005        <path d="M19.4081 3.41559C20.189 2.6347 20.189 1.36655 19.4081 0.585663C18.6272 -0.195221 17.3591 -0.195221 16.5782 0.585663L10 7.17008L3.41559 0.59191C2.6347 -0.188974 1.36655 -0.188974 0.585663 0.59191C-0.195221 1.37279 -0.195221 2.64095 0.585663 3.42183L7.17008 10L0.59191 16.5844C-0.188974 17.3653 -0.188974 18.6335 0.59191 19.4143C1.37279 20.1952 2.64095 20.1952 3.42183 19.4143L10 12.8299L16.5844 19.4081C17.3653 20.189 18.6335 20.189 19.4143 19.4081C20.1952 18.6272 20.1952 17.3591 19.4143 16.5782L12.8299 10L19.4081 3.41559Z"/>
1006      </svg>
1007    </button>`;
1008
1009
1010    const consentTypes = this.config.consentTypes || [];
1011    const acceptedConsentMap = this.getAcceptedConsents();
1012
1013    // Save button
1014    const saveButtonText = this.config.text?.preferences?.saveButtonText || 'Save and close';
1015    const saveButtonLabel = this.config.text?.preferences?.saveButtonAccessibleLabel;
1016    const saveButton = `<button class="stcm-modal-save stcm-button stcm-button-primary"${
1017      saveButtonLabel && saveButtonLabel !== saveButtonText
1018        ? ` aria-label="${saveButtonLabel}"`
1019        : ''
1020    }>${saveButtonText}</button>`;
1021
1022    // Reject button
1023    const rejectNonEssentialButtonText = this.config.text?.prompt?.rejectNonEssentialButtonText || 'Reject non-e
1023ssential';
1024    const rejectNonEssentialButtonLabel = this.config.text?.prompt?.rejectNonEssentialButtonAccessibleLabel;
1025    const rejectNonEssentialButton = `<button class="stcm-modal-reject-all stcm-button stcm-button-primary"${
1026      rejectNonEssentialButtonLabel && rejectNonEssentialButtonLabel !== rejectNonEssentialButtonText
1027        ? ` aria-label="${rejectNonEssentialButtonLabel}"`
1028        : ''
1029    }>${rejectNonEssentialButtonText}</button>`;
1030
1031    // Credit link
1032    const creditLinkText = this.config.text?.preferences?.creditLinkText || 'Get this consent manager for free';
1033    const creditLinkAccessibleLabel = this.config.text?.preferences?.creditLinkAccessibleLabel;
1034    const creditLink = `<a class="stcm-credit-link" href="https://silktide.com/consent-manager" rel="noreferrer"${
1035      creditLinkAccessibleLabel && creditLinkAccessibleLabel !== creditLinkText
1036        ? ` aria-label="${creditLinkAccessibleLabel}"`
1037        : ''
1038    }>${creditLinkText}</a>`;
1039
1040
1041
1042    const modalContent = `
1043      <header>
1044        <h1>${preferencesTitle}</h1>
1045        ${closeModalButton}
1046      </header>
1047      ${preferencesDescription}
1048      <section id="stcm-form">
1049        ${consentTypes
1050          .map((type) => {
1051            const accepted = acceptedConsentMap[type.id];
1052            let isChecked = false;
1053
1054            // if it's accepted then show as checked
1055            if (accepted) {
1056              isChecked = true;
1057            }
1058
1059            // if nothing has been accepted / rejected yet, then show as checked if the default value is true
1060            if (!accepted && !this.hasConsented()) {
1061              isChecked = type.defaultValue;
1062            }
1063
1064            return `
1065            <fieldset>
1066              <legend>${type.label}</legend>
1067              <div class="stcm-consent-row">
1068                <div class="stcm-consent-description">${type.description}</div>
1069                <label class="stcm-toggle" for="consent-${type.id}">
1070                  <input type="checkbox" id="consent-${type.id}" ${
1071              type.required ? 'checked disabled' : isChecked ? 'checked' : ''
1072            } />
1073                  <span class="stcm-toggle-track" aria-hidden="true"></span>
1074                  <span class="stcm-toggle-thumb" aria-hidden="true"></span>
1075                  <span class="stcm-toggle-off" aria-hidden="true">Off</span>
1076                  <span class="stcm-toggle-on" aria-hidden="true">On</span>
1077                </label>
1078              </div>
1079            </fieldset>
1080          `;
1081          })
1082          .join('')}
1083      </section>
1084      <footer>
1085        ${saveButton}
1086        ${rejectNonEssentialButton}
1087        ${creditLink}
1088      </footer>
1089    `;
1090
1091    return modalContent;
1092  }
1093
1094  createModal() {
1095    // Create preferences element
1096    this.preferences = this.createWrapperChild(this.getModalContent(), 'stcm-modal');
1097  }
1098
1099  toggleModal(show) {
1100    if (!this.preferences) return;
1101
1102    this.preferences.style.display = show ? 'flex' : 'none';
1103
1104    if (show) {
1105      this.showBackdrop();
1106      this.hideCookieIcon();
1107      this.removeBanner();
1108      this.preventBodyScroll();
1109
1110      // Focus the close button
1111      const modalCloseButton = this.preferences.querySelector('.stcm-modal-close');
1112      modalCloseButton.focus();
1113
1114      // Trigger optional onPreferencesOpen callback
1115      if (typeof this.config.onPreferencesOpen === 'function') {
1116        this.config.onPreferencesOpen();
1117      }
1118
1119      this.updateCheckboxState(false); // read from storage when opening
1120    } else {
1121      // Close the modal without saving anything - saving is handled by the "Save and Close" and "Reject All" buttons only
1122      this.hideBackdrop();
1123      this.showCookieIcon();
1124      this.allowBodyScroll();
1125
1126      // Trigger optional onPreferencesClose callback
1127      if (typeof this.config.onPreferencesClose === 'function') {
1128        this.config.onPreferencesClose();
1129      }
1130    }
1131  }
1132
1133  // ----------------------------------------------------------------
1134  // Consent Icon
1135  // ----------------------------------------------------------------
1136
1137  /**
1138   * Get cookie icon SVG content
1139   * @private
1140   * @returns {string} SVG string for icon
1141   */
1142  getCookieIconContent() {
1143    return `
1144      <svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
1145        <path d="M19.1172 1.15625C19.0547 0.734374 18.7344 0.390624 18.3125 0.328124C16.5859 0.0859365 14.8281 0.398437 13.2813 1.21875L7.5 4.30469C5.9
11456094 5.125 4.71875 6.41406 3.95313 7.98437L1.08594 13.8906C0.320314 15.4609 0.0703136 17.2422 0.375001 18.9609L1.50781 25.4297C1.8125 27.1562 2.64844 28.7344 3.90625 29.9531L8.61719 34.5156C9.875 35.7344 11.4766 36.5156 13.2031 36.7578L19.6875 37.6719C21.4141 37.9141 23.1719 37.6016 24.7188 36.7812L30.5 33.6953C32.0391 32.875 33.2813 31.5859 34.0469 30.0078L36.9141 24.1094C37.6797 22.5391 37.9297 20.7578 37.625 19.0391C37.5547 18.625 37.2109 18.3125 36.7969 18.25C32.7734 17.6094 29.5469 14.5703 28.6328 10.6406C28.4922 10.0469 28.0078 9.59375 27.4063 9.5C23.1406 8.82031 19.7734 5.4375 19.1094 1.15625H19.1172ZM15.25 10.25C15.913 10.25 16.5489 10.5134 17.0178 10.9822C17.4866 11.4511 17.75 12.087 17.75 12.75C17.75 13.413 17.4866 14.0489 17.0178 14.5178C16.5489 14.9866 15.913 15.25 15.25 15.25C14.587 15.25 13.9511 14.9866 13.4822 14.5178C13.0134 14.0489 12.75 13.413 12.75 12.75C12.75 12.087 13.0134 11.4511 13.4822 10.9822C13.9511 10.5134 14.587 10.25 15.25 10.25ZM10.25 25.25C10.25 24.587 10.5134 23.9511 10.9822 23.4822C11.4511 23.0134 12.087 22.75 12.75 22.75C13.413 22.75 14.0489 23.0134 14.5178 23.4822C14.9866 23.9511 15.25 24.587 15.25 25.25C15.25 25.913 14.9866 26.5489 14.5178 27.0178C14.0489 27.4866 13.413 27.75 12.75 27.75C12.087 27.75 11.4511 27.4866 10.9822 27.0178C10.5134 26.5489 10.25 25.913 10.25 25.25ZM27.75 20.25C28.413 20.25 29.0489 20.5134 29.5178 20.9822C29.9866 21.4511 30.25 22.087 30.25 22.75C30.25 23.413 29.9866 24.0489 29.5178 24.5178C29.0489 24.9866 28.413 25.25 27.75 25.25C27.087 25.25 26.4511 24.9866 25.9822 24.5178C25.5134 24.0489 25.25 23.413 25.25 22.75C25.25 22.087 25.5134 21.4511 25.9822 20.9822C26.4511 20.5134 27.087 20.25 27.75 20.25Z" />
1146      </svg>
1147    `;
1148  }
1149
1150  createCookieIcon() {
1151    this.icon = document.createElement('button');
1152    this.icon.id = 'stcm-icon';
1153    this.icon.title = 'Manage your consent preferences for this site';
1154    this.icon.innerHTML = this.getCookieIconContent();
1155
1156    if (this.config.text?.prompt?.preferencesButtonAccessibleLabel) {
1157      this.icon.ariaLabel = this.config.text?.prompt?.preferencesButtonAccessibleLabel;
1158    }
1159
1160    // Ensure wrapper exists
1161    if (!this.wrapper || !document.body.contains(this.wrapper)) {
1162      this.createWrapper();
1163    }
1164
1165    // Append child to wrapper
1166    this.wrapper.appendChild(this.icon);
1167
1168    // Add positioning class from config
1169    if (this.icon && this.config.icon?.position) {
1170      // Map old position names to new stcm- prefixed names
1171      const positionMap = {
1172        'bottomRight': 'stcm-pos-bottom-right',
1173        'bottomLeft': 'stcm-pos-bottom-left'
1174      };
1175      const mappedPosition = positionMap[this.config.icon.position] || this.config.icon.position;
1176      this.icon.classList.add(mappedPosition);
1177    }
1178
1179    // Add color scheme class from config
1180    if (this.icon && this.config.icon?.colorScheme) {
1181      this.icon.classList.add(this.config.icon.colorScheme);
1182    }
1183  }
1184
1185  showCookieIcon() {
1186    if (this.icon) {
1187      this.icon.style.display = 'flex';
1188    }
1189  }
1190
1191  hideCookieIcon() {
1192    if (this.icon) {
1193      this.icon.style.display = 'none';
1194    }
1195  }
1196
1197  /**
1198   * This runs if the user closes the preferences without making a choice for the first time
1199   * We apply the default values and the necessary values as default
1200   */
1201  handleDefaultConsent() {
1202    this.config.consentTypes.forEach((type) => {
1203      let accepted = true;
1204      // Set localStorage and run accept/reject callbacks
1205      if (type.required || type.defaultValue) {
1206        this.setConsentChoice(type.id, true);
1207      } else {
1208        accepted = false;
1209        this.setConsentChoice(type.id, false);
1210      }
1211
1212      if (accepted) {
1213        if (typeof type.onAccept === 'function') {
1214          type.onAccept();
1215        }
1216      } else {
1217        if (typeof type.onReject === 'function') {
1218          type.onReject();
1219        }
1220      }
1221      // set the flag to say that the consent choice has been made
1222      this.setHasConsented();
1223      this.updateCheckboxState();
1224    });
1225  }
1226
1227  // ----------------------------------------------------------------
1228  // Focusable Elements
1229  // ----------------------------------------------------------------
1230
1231  /**
1232   * Get all focusable elements within a container
1233   * @private
1234   * @param {HTMLElement} element - Container element
1235   * @returns {NodeList} List of focusable elements
1236   */
1237  getFocusableElements(element) {
1238    return element.querySelectorAll(
1239      'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
1240    );
1241  }
1242
1243  // ----------------------------------------------------------------
1244  // Event Listeners
1245  // ----------------------------------------------------------------
1246  setupEventListeners() {
1247    // Check Prompt exists before trying to add event listeners
1248    if (this.prompt) {
1249      // Get the buttons
1250      const acceptButton = this.prompt.querySelector('.stcm-accept-all');
1251      const rejectButton = this.prompt.querySelector('.stcm-reject-all');
1252      const preferencesButton = this.prompt.querySelector('.stcm-preferences-button');
1253
1254      // Add event listeners to the buttons
1255      acceptButton?.addEventListener('click', () => this.handleConsentChoice(true));
1256      rejectButton?.addEventListener('click', () => this.handleConsentChoice(false));
1257      preferencesButton?.addEventListener('click', () => {
1258        this.showBackdrop();
1259        this.toggleModal(true);
1260      });
1261
1262      // Focus Trap
1263      const focusableElements = this.getFocusableElements(this.prompt);
1264      const firstFocusableEl = focusableElements[0];
1265      const lastFocusableEl = focusableElements[focusableElements.length - 1];
1266
1267      // Add keydown event listener to handle tab navigation
1268      this.prompt.addEventListener('keydown', (e) => {
1269        if (e.key === 'Tab') {
1270          if (e.shiftKey) {
1271            if (document.activeElement === firstFocusableEl) {
1272              lastFocusableEl.focus();
1273              e.preventDefault();
1274            }
1275          } else {
1276            if (document.activeElement === lastFocusableEl) {
1277              firstFocusableEl.focus();
1278              e.preventDefault();
1279            }
1280          }
1281        }
1282      });
1283
1284      // Set initial focus
1285      if (this.config.mode !== 'wizard') {
1286        acceptButton?.focus();
1287      }
1288    }
1289
1290    // Check Preferences exists before trying to add event listeners
1291    if (this.preferences) {
1292      const closeButton = this.preferences.querySelector('.stcm-modal-close');
1293      const saveButton = this.preferences.querySelector('.stcm-modal-save');
1294      const rejectAllButton = this.preferences.querySelector('.stcm-modal-reject-all');
1295
1296      // Close button - only closes modal, doesn't save or fire events
1297      closeButton?.addEventListener('click', () => {
1298        this.toggleModal(false);
1299        this.hideBackdrop();
1300
1301        // If user hasn't made initial choice, show prompt again next time
1302        // Otherwise, just close without doing anything
1303      });
1304
1305      // Save button - reads checkbox states and batch updates
1306      saveButton?.addEventListener('click', () => {
1307        // We set that an initial choice was made
1308        this.setHasConsented();
1309
1310        // Read current checkbox states from the modal
1311        const preferencesSection = this.preferences.querySelector('#stcm-form');
1312        const checkboxes = preferencesSection.querySelectorAll('input[type="checkbox"]');
1313        const consentStates = {};
1314
1315        checkboxes.forEach(checkbox => {
1316          const [, consentId] = checkbox.id.split('consent-');
1317          consentStates[consentId] = checkbox.checked;
1318        });
1319
1320        // Use batch update to set all consents at once (only fires if changes detected)
1321        this.batchUpdateConsents(consentStates);
1322
1323        // Close modal and show icon
1324        this.toggleModal(false);
1325        this.hideBackdrop();
1326        this.removeBanner();
1327        this.showCookieIcon();
1328      });
1329
1330      // Reject All button - sets required to true, all others to false, then batch updates
1331      rejectAllButton?.addEventListener('click', () => {
1332        // We set that an initial choice was made
1333        this.setHasConsented();
1334
1335        // First, update the checkbox UI to reflect rejection
1336        const preferencesSection = this.preferences.querySelector('#stcm-form');
1337        const checkboxes = preferencesSection.querySelectorAll('input[type="checkbox"]');
1338
1339        checkboxes.forEach(checkbox => {
1340          const [, consentId] = checkbox.id.split('consent-');
1341          const consentType = this.config.consentTypes.find(type => type.id === consentId);
1342
1343          if (consentType && !consentType.required) {
1344            checkbox.checked = false;
1345          }
1346        });
1347
1348        // Build consent states: required = true, optional = false
1349        const consentStates = {};
1350        this.config.consentTypes.forEach((type) => {
1351          consentStates[type.id] = type.required ? true : false;
1352        });
1353
1354        // Use batch update to set all consents at once
1355        this.batchUpdateConsents(consentStates);
1356
1357        // Close modal and show icon
1358        this.toggleModal(false);
1359        this.hideBackdrop();
1360        this.removeBanner();
1361        this.showCookieIcon();
1362      });
1363
1364      // Preferences Focus Trap
1365      const focusableElements = this.getFocusableElements(this.preferences);
1366      const firstFocusableEl = focusableElements[0];
1367      const lastFocusableEl = focusableElements[focusableElements.length - 1];
1368
1369      this.preferences.addEventListener('keydown', (e) => {
1370        if (e.key === 'Tab') {
1371          if (e.shiftKey) {
1372            if (document.activeElement === firstFocusableEl) {
1373              lastFocusableEl.focus();
1374              e.preventDefault();
1375            }
1376          } else {
1377            if (document.activeElement === lastFocusableEl) {
1378              firstFocusableEl.focus();
1379              e.preventDefault();
1380            }
1381          }
1382        }
1383        if (e.key === 'Escape') {
1384          this.toggleModal(false);
1385        }
1386      });
1387
1388      closeButton?.focus();
1389
1390      // Note: Checkboxes no longer trigger immediate consent updates
1391      // Users can toggle them freely, and consent is only updated when clicking "Save and Close"
1392    }
1393
1394    // Check Icon exists before trying to add event listeners
1395    if (this.icon) {
1396      this.icon.addEventListener('click', () => {
1397        // If preferences is not found, create it
1398        if (!this.preferences) {
1399          this.createModal();
1400          this.toggleModal(true);
1401          this.hideCookieIcon();
1402        }
1403        // If preferences is hidden, show it
1404        else if (this.preferences.style.display === 'none' || this.preferences.style.display === '') {
1405          this.toggleModal(true);
1406          this.hideCookieIcon();
1407        }
1408        // If preferences is visible, hide it
1409        else {
1410          this.toggleModal(false);
1411        }
1412      });
1413    }
1414  }
1415
1416  preventBodyScroll() {
1417    document.body.style.overflow = 'hidden';
1418    // Prevent iOS Safari scrolling
1419    document.body.style.position = 'fixed';
1420    document.body.style.width = '100%';
1421  }
1422
1423  allowBodyScroll() {
1424    document.body.style.overflow = '';
1425    document.body.style.position = '';
1426    document.body.style.width = '';
1427  }
1428}
1429
1430/**
1431 * ============================================================================
1432 * Public API (IIFE Wrapper)
1433 * ============================================================================
1434 */
1435(function () {
1436  window.silktideConsentManager = {};
1437
1438  let consentManager;
1439
1440  /**
1441   * Initialize the consent manager with the given configuration
1442   * Destroys any existing instance and creates a new one.
1443   *
1444   * @param {Object} config - Configuration object
1445   */
1446  function init(config = {}) {
1447    // Function to create instance
1448    const create = () => {
1449      // Destroy existing instance if present
1450      if (consentManager) {
1451        consentManager.destroy();
1452        consentManager = null;
1453      }
1454
1455      // Create new instance (class constructor validates config)
1456      consentManager = new SilktideConsentManager(config);
1457    };
1458
1459    // Initialize immediately if DOM ready, otherwise wait
1460    if (document.body) {
1461      create();
1462    } else {
1463      document.addEventListener('DOMContentLoaded', create, {once: true});
1464    }
1465  }
1466
1467  /**
1468   * Update the configuration by deep merging with existing config
1469   * Requires an existing instance to be initialized first.
1470   *
1471   * @param {Object} newConfig - Partial configuration object to merge with existing config
1472   */
1473  function update(newConfig = {}) {
1474    if (!consentManager) {
1475      console.error('Silktide Consent Manager: Cannot update - no instance initialized. Call init() first.');
1476      return;
1477    }
1478
1479    function deepMerge(target, source) {
1480      const output = { ...target };
1481      for (const key in source) {
1482        if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
1483          output[key] = deepMerge(target[key] || {}, source[key]);
1484        } else {
1485          output[key] = source[key];
1486        }
1487      }
1488      return output;
1489    }
1490
1491    // Deep merge new config into existing and re-initialize
1492    const mergedConfig = deepMerge(consentManager.config, newConfig);
1493    init(mergedConfig);
1494  }
1495
1496  /**
1497   * Reset all consent choices and show the prompt again
1498   * Clears all localStorage entries and reinitializes as if fresh page load
1499   */
1500  function resetConsent() {
1501    if (!consentManager) {
1502      console.error('Silktide Consent Manager: Cannot reset - no instance initialized.');
1503      return;
1504    }
1505
1506    // Clear all consent-related localStorage
1507    consentManager.clearAllConsents();
1508
1509    // Re-initialize with current config from instance
1510    init(consentManager.config);
1511  }
1512
1513  /**
1514   * Get the current consent manager instance (for advanced usage)
1515   * @returns {SilktideConsentManager|null} Current instance or null if not initialized
1516   */
1517  function getInstance() {
1518    return consentManager;
1519  }
1520
1521  window.silktideConsentManager.init = init;
1522  window.silktideConsentManager.update = update;
1523  window.silktideConsentManager.getInstance = getInstance;
1524  window.silktideConsentManager.resetConsent = resetConsent;
1525})();

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.