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.