cookiecraft 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,13 +43,20 @@ class StorageManager {
43
43
  * Clear consent record from localStorage
44
44
  */
45
45
  clear() {
46
- localStorage.removeItem(StorageManager.STORAGE_KEY);
46
+ try {
47
+ localStorage.removeItem(StorageManager.STORAGE_KEY);
48
+ }
49
+ catch (e) {
50
+ console.error('Failed to clear consent:', e);
51
+ }
47
52
  }
48
53
  /**
49
54
  * Check if consent record has expired
50
55
  */
51
56
  isExpired(consent) {
52
57
  const expiry = new Date(consent.expiresAt);
58
+ if (isNaN(expiry.getTime()))
59
+ return true;
53
60
  return expiry < new Date();
54
61
  }
55
62
  /**
@@ -60,7 +67,6 @@ class StorageManager {
60
67
  typeof data.version === 'number' &&
61
68
  typeof data.timestamp === 'string' &&
62
69
  typeof data.categories === 'object' &&
63
- typeof data.userAgent === 'string' &&
64
70
  typeof data.expiresAt === 'string');
65
71
  }
66
72
  /**
@@ -76,11 +82,20 @@ class StorageManager {
76
82
  const now = new Date();
77
83
  const expiryDate = new Date(now);
78
84
  expiryDate.setMonth(expiryDate.getMonth() + StorageManager.EXPIRY_MONTHS);
85
+ // Coerce category values to booleans
86
+ const rawCategories = record.categories;
87
+ const categories = {
88
+ necessary: rawCategories.necessary === true,
89
+ analytics: rawCategories.analytics === true,
90
+ marketing: rawCategories.marketing === true,
91
+ };
92
+ if ('preferences' in rawCategories) {
93
+ categories.preferences = rawCategories.preferences === true;
94
+ }
79
95
  return {
80
96
  version: typeof record.version === 'number' ? record.version : 1,
81
97
  timestamp: typeof record.timestamp === 'string' ? record.timestamp : now.toISOString(),
82
- categories: record.categories,
83
- userAgent: typeof record.userAgent === 'string' ? record.userAgent : navigator.userAgent,
98
+ categories,
84
99
  expiresAt: typeof record.expiresAt === 'string' ? record.expiresAt : expiryDate.toISOString(),
85
100
  };
86
101
  }
@@ -95,6 +110,7 @@ StorageManager.EXPIRY_MONTHS = 13;
95
110
  */
96
111
  class ConsentManager {
97
112
  constructor(config) {
113
+ this.consent = null;
98
114
  this.config = config;
99
115
  }
100
116
  /**
@@ -113,6 +129,10 @@ class ConsentManager {
113
129
  }
114
130
  }
115
131
  }
132
+ // Coerce all values to booleans
133
+ for (const key of Object.keys(categories)) {
134
+ categories[key] = categories[key] === true;
135
+ }
116
136
  return true;
117
137
  }
118
138
  /**
@@ -129,13 +149,12 @@ class ConsentManager {
129
149
  * Check if user needs to give consent
130
150
  */
131
151
  needsConsent() {
132
- return this.consent === undefined;
152
+ return this.consent === null;
133
153
  }
134
154
  /**
135
155
  * Check if stored consent needs update due to policy change
136
156
  */
137
157
  needsUpdate(storedConsent) {
138
- // Check if policy version has changed
139
158
  return storedConsent.version < this.config.revision;
140
159
  }
141
160
  /**
@@ -155,7 +174,6 @@ class ConsentManager {
155
174
  version: this.config.revision,
156
175
  timestamp: now.toISOString(),
157
176
  categories: Object.assign({}, categories),
158
- userAgent: navigator.userAgent,
159
177
  expiresAt: expiryDate.toISOString(),
160
178
  };
161
179
  }
@@ -396,7 +414,6 @@ class ScriptBlocker {
396
414
  class CategoryManager {
397
415
  constructor() {
398
416
  this.categories = new Map();
399
- // Initialize with common patterns
400
417
  this.initializeDefaultPatterns();
401
418
  }
402
419
  /**
@@ -433,11 +450,11 @@ class CategoryManager {
433
450
  }
434
451
  /**
435
452
  * Initialize default URL patterns for common tracking services
453
+ * Note: GTM is NOT auto-categorized — it should be managed via GTM Consent Mode v2
436
454
  */
437
455
  initializeDefaultPatterns() {
438
456
  this.categories.set('analytics', [
439
457
  'google-analytics.com',
440
- 'googletagmanager.com',
441
458
  'analytics.google.com',
442
459
  'plausible.io',
443
460
  'matomo.org',
@@ -447,6 +464,7 @@ class CategoryManager {
447
464
  'amplitude.com',
448
465
  ]);
449
466
  this.categories.set('marketing', [
467
+ 'googletagmanager.com',
450
468
  'facebook.net',
451
469
  'facebook.com/tr',
452
470
  'connect.facebook.net',
@@ -458,6 +476,7 @@ class CategoryManager {
458
476
  'adroll.com',
459
477
  'taboola.com',
460
478
  'outbrain.com',
479
+ 'tiktok.com',
461
480
  ]);
462
481
  this.categories.set('necessary', []);
463
482
  }
@@ -508,18 +527,47 @@ function sanitizeColor(color) {
508
527
  // Allow hsl/hsla
509
528
  if (/^hsla?\(\s*[\d\s,./%deg]+\)$/.test(trimmed))
510
529
  return trimmed;
511
- // Allow CSS named colors (basic set)
512
- if (/^[a-zA-Z]+$/.test(trimmed))
530
+ // Allow CSS named colors (basic set) but block CSS keywords that could be abused
531
+ const CSS_KEYWORDS = ['inherit', 'initial', 'unset', 'revert', 'revert-layer'];
532
+ if (/^[a-zA-Z]+$/.test(trimmed) && !CSS_KEYWORDS.includes(trimmed.toLowerCase())) {
513
533
  return trimmed;
534
+ }
514
535
  return '';
515
536
  }
516
537
 
538
+ /**
539
+ * Normalize any supported color format to 6-digit hex
540
+ * Supports: #RGB, #RRGGBB, #RRGGBBAA, named colors
541
+ * Returns null if conversion fails
542
+ */
543
+ function normalizeToHex6(color) {
544
+ const trimmed = color.trim();
545
+ // Already 6-digit hex
546
+ if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
547
+ return trimmed;
548
+ }
549
+ // 3-digit hex → expand to 6-digit
550
+ if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
551
+ const r = trimmed[1];
552
+ const g = trimmed[2];
553
+ const b = trimmed[3];
554
+ return `#${r}${r}${g}${g}${b}${b}`;
555
+ }
556
+ // 8-digit hex (with alpha) → strip alpha
557
+ if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) {
558
+ return trimmed.substring(0, 7);
559
+ }
560
+ return null;
561
+ }
517
562
  /**
518
563
  * Adjust a hex color brightness by a percentage
519
564
  * Negative = darker, positive = lighter
520
565
  */
521
566
  function adjustColorBrightness(color, percent) {
522
- const hex = color.replace('#', '');
567
+ const hex6 = normalizeToHex6(color);
568
+ if (!hex6)
569
+ return color;
570
+ const hex = hex6.replace('#', '');
523
571
  const r = parseInt(hex.substring(0, 2), 16);
524
572
  const g = parseInt(hex.substring(2, 4), 16);
525
573
  const b = parseInt(hex.substring(4, 6), 16);
@@ -539,8 +587,13 @@ function adjustColorBrightness(color, percent) {
539
587
  function buildColorStyle(safeColor) {
540
588
  if (!safeColor)
541
589
  return '';
542
- const hover = adjustColorBrightness(safeColor, -15);
543
- return `--cc-primary: ${safeColor}; --cc-primary-hover: ${hover};`;
590
+ // Only generate hover color for hex colors
591
+ const hex6 = normalizeToHex6(safeColor);
592
+ if (!hex6) {
593
+ return `--cc-primary: ${safeColor};`;
594
+ }
595
+ const hover = adjustColorBrightness(hex6, -15);
596
+ return `--cc-primary: ${hex6}; --cc-primary-hover: ${hover};`;
544
597
  }
545
598
 
546
599
  /**
@@ -549,6 +602,8 @@ function buildColorStyle(safeColor) {
549
602
  class Banner {
550
603
  constructor(config, eventEmitter) {
551
604
  this.element = null;
605
+ this.hideTimeout = null;
606
+ this.previousActiveElement = null;
552
607
  this.config = config;
553
608
  this.eventEmitter = eventEmitter;
554
609
  }
@@ -556,8 +611,14 @@ class Banner {
556
611
  * Show the banner
557
612
  */
558
613
  show() {
614
+ // Clear any pending hide timeout
615
+ if (this.hideTimeout) {
616
+ clearTimeout(this.hideTimeout);
617
+ this.hideTimeout = null;
618
+ }
559
619
  const append = () => {
560
620
  if (!this.element) {
621
+ this.previousActiveElement = document.activeElement;
561
622
  this.element = this.createDOM();
562
623
  document.body.appendChild(this.element);
563
624
  this.attachListeners();
@@ -570,9 +631,9 @@ class Banner {
570
631
  // Disable page interaction if configured
571
632
  if (this.config.disablePageInteraction) {
572
633
  document.body.style.overflow = 'hidden';
634
+ this.trapFocus();
573
635
  }
574
636
  };
575
- // Wait for body if not yet available
576
637
  if (!document.body) {
577
638
  document.addEventListener('DOMContentLoaded', append);
578
639
  return;
@@ -585,22 +646,30 @@ class Banner {
585
646
  hide() {
586
647
  var _a;
587
648
  (_a = this.element) === null || _a === void 0 ? void 0 : _a.classList.remove('is-visible');
588
- // Re-enable page interaction
589
649
  if (this.config.disablePageInteraction) {
590
650
  document.body.style.overflow = '';
591
651
  }
592
- setTimeout(() => {
652
+ this.hideTimeout = setTimeout(() => {
593
653
  this.destroy();
594
- }, 300); // Match CSS transition
654
+ }, 300);
595
655
  }
596
656
  /**
597
657
  * Destroy the banner
598
658
  */
599
659
  destroy() {
660
+ if (this.hideTimeout) {
661
+ clearTimeout(this.hideTimeout);
662
+ this.hideTimeout = null;
663
+ }
600
664
  if (this.element) {
601
665
  this.element.remove();
602
666
  this.element = null;
603
667
  }
668
+ // Restore focus
669
+ if (this.previousActiveElement && document.contains(this.previousActiveElement)) {
670
+ this.previousActiveElement.focus();
671
+ this.previousActiveElement = null;
672
+ }
604
673
  }
605
674
  /**
606
675
  * Create DOM structure for banner
@@ -611,12 +680,14 @@ class Banner {
611
680
  const position = this.config.position || 'bottom';
612
681
  const layout = this.config.layout || 'bar';
613
682
  const backdropBlur = this.config.backdropBlur !== false;
683
+ const isModal = this.config.disablePageInteraction;
614
684
  const safeColor = this.config.primaryColor ? sanitizeColor(this.config.primaryColor) : '';
615
685
  const colorStyle = buildColorStyle(safeColor);
616
686
  const template = `
617
687
  <div
618
688
  class="cc-banner cc-banner--${escapeHtml(position)} cc-banner--${escapeHtml(layout)} ${backdropBlur ? 'cc-backdrop-blur' : ''}"
619
- role="region"
689
+ role="${isModal ? 'dialog' : 'region'}"
690
+ ${isModal ? 'aria-modal="true"' : ''}
620
691
  aria-label="Cookie consent"
621
692
  aria-live="polite"
622
693
  data-theme="${escapeHtml(theme)}"
@@ -625,7 +696,7 @@ class Banner {
625
696
  <div class="cc-banner__container">
626
697
  <div class="cc-banner__content">
627
698
  <h2 class="cc-banner__title">
628
- ${escapeHtml(translations.title || '🍪 Nous utilisons des cookies')}
699
+ ${escapeHtml(translations.title || 'We use cookies')}
629
700
  </h2>
630
701
  <p class="cc-banner__description">
631
702
  ${this.getDescriptionHTML()}
@@ -635,23 +706,23 @@ class Banner {
635
706
  <button
636
707
  class="cc-btn cc-btn--ghost"
637
708
  data-action="reject"
638
- aria-label="${escapeHtml(translations.rejectAll || 'Uniquement essentiels')}"
709
+ aria-label="${escapeHtml(translations.rejectAll || 'Essentials only')}"
639
710
  >
640
- ${escapeHtml(translations.rejectAll || 'Uniquement essentiels')}
711
+ ${escapeHtml(translations.rejectAll || 'Essentials only')}
641
712
  </button>
642
713
  <button
643
714
  class="cc-btn cc-btn--tertiary"
644
715
  data-action="customize"
645
- aria-label="${escapeHtml(translations.customize || 'Personnaliser')}"
716
+ aria-label="${escapeHtml(translations.customize || 'Customize')}"
646
717
  >
647
- ${escapeHtml(translations.customize || 'Personnaliser')}
718
+ ${escapeHtml(translations.customize || 'Customize')}
648
719
  </button>
649
720
  <button
650
721
  class="cc-btn cc-btn--accept"
651
722
  data-action="accept"
652
- aria-label="${escapeHtml(translations.acceptAll || 'Tout accepter')}"
723
+ aria-label="${escapeHtml(translations.acceptAll || 'Accept all')}"
653
724
  >
654
- ${escapeHtml(translations.acceptAll || 'Tout accepter')}
725
+ ${escapeHtml(translations.acceptAll || 'Accept all')}
655
726
  </button>
656
727
  </div>
657
728
  </div>
@@ -683,10 +754,8 @@ class Banner {
683
754
  break;
684
755
  }
685
756
  });
686
- // Keyboard support
687
757
  (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
688
758
  if (e.key === 'Escape' && this.config.disablePageInteraction) {
689
- // Allow ESC to close if page interaction is disabled
690
759
  this.handleRejectAll();
691
760
  }
692
761
  });
@@ -695,36 +764,24 @@ class Banner {
695
764
  * Handle accept all action
696
765
  */
697
766
  handleAcceptAll() {
698
- var _a, _b, _c;
699
- const allCategories = {
700
- necessary: true,
701
- analytics: true,
702
- marketing: true,
703
- };
704
- // Only add preferences if it's configured
705
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
706
- allCategories.preferences = true;
767
+ const allCategories = { necessary: true, analytics: true, marketing: true };
768
+ // Add all configured categories
769
+ for (const key of Object.keys(this.config.categories)) {
770
+ allCategories[key] = true;
707
771
  }
708
772
  this.eventEmitter.emit('consent:accept', allCategories);
709
- (_c = (_b = this.config).onAccept) === null || _c === void 0 ? void 0 : _c.call(_b, allCategories);
710
773
  this.hide();
711
774
  }
712
775
  /**
713
776
  * Handle reject all action
714
777
  */
715
778
  handleRejectAll() {
716
- var _a, _b, _c;
717
- const necessaryOnly = {
718
- necessary: true,
719
- analytics: false,
720
- marketing: false,
721
- };
722
- // Only add preferences if it's configured
723
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
724
- necessaryOnly.preferences = false;
779
+ const necessaryOnly = { necessary: true, analytics: false, marketing: false };
780
+ for (const key of Object.keys(this.config.categories)) {
781
+ if (key !== 'necessary')
782
+ necessaryOnly[key] = false;
725
783
  }
726
784
  this.eventEmitter.emit('consent:reject', necessaryOnly);
727
- (_c = (_b = this.config).onReject) === null || _c === void 0 ? void 0 : _c.call(_b);
728
785
  this.hide();
729
786
  }
730
787
  /**
@@ -734,17 +791,41 @@ class Banner {
734
791
  this.eventEmitter.emit('preferences:show');
735
792
  this.hide();
736
793
  }
794
+ /**
795
+ * Trap focus within banner (when disablePageInteraction is true)
796
+ */
797
+ trapFocus() {
798
+ var _a, _b;
799
+ const focusableElements = (_a = this.element) === null || _a === void 0 ? void 0 : _a.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
800
+ if (!focusableElements || focusableElements.length === 0)
801
+ return;
802
+ const firstFocusable = focusableElements[0];
803
+ const lastFocusable = focusableElements[focusableElements.length - 1];
804
+ firstFocusable === null || firstFocusable === void 0 ? void 0 : firstFocusable.focus();
805
+ (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
806
+ if (e.key === 'Tab') {
807
+ if (e.shiftKey && document.activeElement === firstFocusable) {
808
+ e.preventDefault();
809
+ lastFocusable.focus();
810
+ }
811
+ else if (!e.shiftKey && document.activeElement === lastFocusable) {
812
+ e.preventDefault();
813
+ firstFocusable.focus();
814
+ }
815
+ }
816
+ });
817
+ }
737
818
  /**
738
819
  * Generate description HTML with privacy policy link
739
820
  */
740
821
  getDescriptionHTML() {
741
822
  const translations = this.config.translations || {};
742
- const defaultDescription = 'Pour améliorer votre expérience sur notre site, nous utilisons des cookies. Vous pouvez choisir les cookies que vous acceptez.';
823
+ const defaultDescription = 'We use cookies to improve your experience on our site. You can choose which cookies you accept.';
743
824
  const description = escapeHtml(translations.description || defaultDescription);
744
825
  if (translations.privacyPolicyUrl) {
745
826
  const safeUrl = sanitizeUrl(translations.privacyPolicyUrl);
746
827
  if (safeUrl) {
747
- const linkLabel = escapeHtml(translations.privacyPolicyLabel || 'Politique de confidentialité');
828
+ const linkLabel = escapeHtml(translations.privacyPolicyLabel || 'Privacy Policy');
748
829
  return `${description} <a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkLabel}</a>`;
749
830
  }
750
831
  }
@@ -758,6 +839,7 @@ class Banner {
758
839
  class PreferenceCenter {
759
840
  constructor(config, eventEmitter, currentConsent) {
760
841
  this.element = null;
842
+ this.previousActiveElement = null;
761
843
  this.config = config;
762
844
  this.eventEmitter = eventEmitter;
763
845
  this.currentConsent = currentConsent;
@@ -768,13 +850,13 @@ class PreferenceCenter {
768
850
  show() {
769
851
  const append = () => {
770
852
  if (!this.element) {
853
+ this.previousActiveElement = document.activeElement;
771
854
  this.element = this.createDOM();
772
855
  document.body.appendChild(this.element);
773
856
  this.attachListeners();
774
857
  }
775
858
  this.element.classList.add('is-visible');
776
859
  this.trapFocus();
777
- // Prevent body scroll
778
860
  document.body.style.overflow = 'hidden';
779
861
  };
780
862
  if (!document.body) {
@@ -790,6 +872,11 @@ class PreferenceCenter {
790
872
  var _a;
791
873
  (_a = this.element) === null || _a === void 0 ? void 0 : _a.classList.remove('is-visible');
792
874
  document.body.style.overflow = '';
875
+ // Restore focus to triggering element
876
+ if (this.previousActiveElement && document.contains(this.previousActiveElement)) {
877
+ this.previousActiveElement.focus();
878
+ this.previousActiveElement = null;
879
+ }
793
880
  setTimeout(() => {
794
881
  this.destroy();
795
882
  }, 300);
@@ -824,7 +911,7 @@ class PreferenceCenter {
824
911
  <polyline points="15 3 21 3 21 9"/>
825
912
  <line x1="10" y1="14" x2="21" y2="3"/>
826
913
  </svg>
827
- ${escapeHtml(translations.privacyPolicyLabel || 'Politique de confidentialité')}
914
+ ${escapeHtml(translations.privacyPolicyLabel || 'Privacy Policy')}
828
915
  </a>
829
916
  `;
830
917
  })()
@@ -838,21 +925,12 @@ class PreferenceCenter {
838
925
  data-theme="${escapeHtml(theme)}"
839
926
  style="${colorStyle}"
840
927
  >
841
- <div class="cc-modal__overlay" data-action="close"></div>
928
+ <div class="cc-modal__overlay"></div>
842
929
  <div class="cc-modal__content">
843
930
  <div class="cc-modal__header">
844
931
  <h2 id="cc-modal-title">
845
- ${escapeHtml(translations.preferencesTitle || translations.title || 'Préférences de cookies')}
932
+ ${escapeHtml(translations.preferencesTitle || translations.title || 'Cookie Preferences')}
846
933
  </h2>
847
- <button
848
- class="cc-modal__close"
849
- aria-label="Fermer"
850
- data-action="close"
851
- >
852
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
853
- <path d="M18 6L6 18M6 6l12 12" stroke-width="2" stroke-linecap="round"/>
854
- </svg>
855
- </button>
856
934
  </div>
857
935
 
858
936
  <div class="cc-modal__body">
@@ -868,13 +946,13 @@ class PreferenceCenter {
868
946
  class="cc-btn cc-btn--secondary"
869
947
  data-action="reject"
870
948
  >
871
- ${escapeHtml(translations.essentialsOnly || 'Uniquement les essentiels')}
949
+ ${escapeHtml(translations.essentialsOnly || 'Essentials only')}
872
950
  </button>
873
951
  <button
874
952
  class="cc-btn cc-btn--primary"
875
953
  data-action="save"
876
954
  >
877
- ${escapeHtml(translations.savePreferences || 'Enregistrer mes choix')}
955
+ ${escapeHtml(translations.savePreferences || 'Save preferences')}
878
956
  </button>
879
957
  </div>
880
958
  </div>
@@ -892,7 +970,7 @@ class PreferenceCenter {
892
970
  const categories = Object.entries(this.config.categories);
893
971
  return categories
894
972
  .map(([key, config]) => {
895
- const checked = this.currentConsent[key];
973
+ const checked = this.currentConsent[key] === true;
896
974
  const disabled = config.readOnly;
897
975
  return `
898
976
  <div class="cc-category">
@@ -921,40 +999,34 @@ class PreferenceCenter {
921
999
  * Attach event listeners
922
1000
  */
923
1001
  attachListeners() {
924
- var _a, _b;
1002
+ var _a;
925
1003
  (_a = this.element) === null || _a === void 0 ? void 0 : _a.addEventListener('click', (e) => {
926
1004
  const target = e.target.closest('[data-action]');
927
1005
  if (!target)
928
1006
  return;
929
1007
  const action = target.getAttribute('data-action');
930
- if (action === 'close') {
931
- this.hide();
932
- }
933
- else if (action === 'save') {
1008
+ if (action === 'save') {
934
1009
  this.handleSave();
935
1010
  }
936
1011
  else if (action === 'reject') {
937
1012
  this.handleRejectAll();
938
1013
  }
939
1014
  });
940
- // Keyboard shortcuts
941
- (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
942
- if (e.key === 'Escape') {
943
- this.hide();
944
- }
945
- });
946
1015
  }
947
1016
  /**
948
1017
  * Handle save preferences
949
1018
  */
950
1019
  handleSave() {
951
- var _a, _b, _c;
1020
+ var _a;
952
1021
  const checkboxes = (_a = this.element) === null || _a === void 0 ? void 0 : _a.querySelectorAll('input[data-category]');
953
- const categories = {
954
- necessary: true,
955
- analytics: false,
956
- marketing: false,
957
- };
1022
+ // Initialize all configured categories to false
1023
+ const categories = { necessary: true, analytics: false, marketing: false };
1024
+ for (const key of Object.keys(this.config.categories)) {
1025
+ if (key !== 'necessary') {
1026
+ categories[key] = false;
1027
+ }
1028
+ }
1029
+ // Override with actual checkbox values
958
1030
  checkboxes === null || checkboxes === void 0 ? void 0 : checkboxes.forEach((checkbox) => {
959
1031
  if (checkbox instanceof HTMLInputElement) {
960
1032
  const category = checkbox.getAttribute('data-category');
@@ -964,22 +1036,16 @@ class PreferenceCenter {
964
1036
  }
965
1037
  });
966
1038
  this.eventEmitter.emit('consent:update', categories);
967
- (_c = (_b = this.config).onChange) === null || _c === void 0 ? void 0 : _c.call(_b, categories);
968
1039
  this.hide();
969
1040
  }
970
1041
  /**
971
1042
  * Handle reject all
972
1043
  */
973
1044
  handleRejectAll() {
974
- var _a;
975
- const necessaryOnly = {
976
- necessary: true,
977
- analytics: false,
978
- marketing: false,
979
- };
980
- // Only add preferences if it's configured
981
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
982
- necessaryOnly.preferences = false;
1045
+ const necessaryOnly = { necessary: true, analytics: false, marketing: false };
1046
+ for (const key of Object.keys(this.config.categories)) {
1047
+ if (key !== 'necessary')
1048
+ necessaryOnly[key] = false;
983
1049
  }
984
1050
  this.eventEmitter.emit('consent:reject', necessaryOnly);
985
1051
  this.hide();
@@ -994,9 +1060,7 @@ class PreferenceCenter {
994
1060
  return;
995
1061
  const firstFocusable = focusableElements[0];
996
1062
  const lastFocusable = focusableElements[focusableElements.length - 1];
997
- // Focus first element
998
1063
  firstFocusable === null || firstFocusable === void 0 ? void 0 : firstFocusable.focus();
999
- // Trap focus
1000
1064
  (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
1001
1065
  if (e.key === 'Tab') {
1002
1066
  if (e.shiftKey && document.activeElement === firstFocusable) {
@@ -1084,7 +1148,7 @@ class FloatingWidget {
1084
1148
  <div
1085
1149
  class="cc-widget cc-widget--${escapeHtml(widgetPosition)} cc-widget--${escapeHtml(widgetStyle)}"
1086
1150
  role="button"
1087
- aria-label="${escapeHtml(translations.cookieSettings || 'Paramètres des cookies')}"
1151
+ aria-label="${escapeHtml(translations.cookieSettings || 'Cookie settings')}"
1088
1152
  tabindex="0"
1089
1153
  data-theme="${escapeHtml(theme)}"
1090
1154
  style="${colorStyle}"
@@ -1135,11 +1199,6 @@ class FloatingWidget {
1135
1199
 
1136
1200
  /**
1137
1201
  * GTMConsentMode - Full integration with Google Consent Mode v2
1138
- *
1139
- * Implements all required signals:
1140
- * - ad_storage, ad_user_data, ad_personalization, analytics_storage (core GCM v2)
1141
- * - functionality_storage, personalization_storage, security_storage (non-core)
1142
- * - wait_for_update, url_passthrough, ads_data_redaction (advanced features)
1143
1202
  */
1144
1203
  class GTMConsentMode {
1145
1204
  constructor(dataLayerManager, config) {
@@ -1159,15 +1218,13 @@ class GTMConsentMode {
1159
1218
  analytics_storage: 'denied',
1160
1219
  functionality_storage: 'denied',
1161
1220
  personalization_storage: 'denied',
1162
- security_storage: 'granted', // Always granted
1221
+ security_storage: 'granted',
1163
1222
  };
1164
- // Add wait_for_update to give CMP time to restore returning visitor consent
1165
1223
  const waitForUpdate = (_a = this.config.gtmWaitForUpdate) !== null && _a !== void 0 ? _a : 500;
1166
1224
  if (waitForUpdate > 0) {
1167
1225
  defaults['wait_for_update'] = waitForUpdate;
1168
1226
  }
1169
1227
  this.dataLayerManager.pushConsent('default', defaults);
1170
- // Set advanced features via gtag('set', ...)
1171
1228
  if (this.config.gtmUrlPassthrough) {
1172
1229
  this.dataLayerManager.pushSet('url_passthrough', true);
1173
1230
  }
@@ -1177,7 +1234,6 @@ class GTMConsentMode {
1177
1234
  }
1178
1235
  /**
1179
1236
  * Update consent state based on user choices
1180
- * Called both on new consent and on page load for returning visitors
1181
1237
  */
1182
1238
  updateConsent(categories) {
1183
1239
  const gtmConsent = this.mapCategoriesToGTM(categories);
@@ -1187,14 +1243,19 @@ class GTMConsentMode {
1187
1243
  * Map consent categories to GTM Consent Mode v2 format
1188
1244
  */
1189
1245
  mapCategoriesToGTM(categories) {
1246
+ // When preferences category is not configured, default functionality to granted
1247
+ const hasPreferencesCategory = 'preferences' in this.config.categories;
1248
+ const preferencesGranted = hasPreferencesCategory
1249
+ ? categories.preferences === true
1250
+ : true;
1190
1251
  return {
1191
1252
  ad_storage: categories.marketing ? 'granted' : 'denied',
1192
1253
  ad_user_data: categories.marketing ? 'granted' : 'denied',
1193
1254
  ad_personalization: categories.marketing ? 'granted' : 'denied',
1194
1255
  analytics_storage: categories.analytics ? 'granted' : 'denied',
1195
- functionality_storage: categories.preferences ? 'granted' : 'denied',
1196
- personalization_storage: categories.preferences ? 'granted' : 'denied',
1197
- security_storage: 'granted', // Always granted
1256
+ functionality_storage: preferencesGranted ? 'granted' : 'denied',
1257
+ personalization_storage: preferencesGranted ? 'granted' : 'denied',
1258
+ security_storage: 'granted',
1198
1259
  };
1199
1260
  }
1200
1261
  }
@@ -1339,6 +1400,16 @@ class CookieConsent {
1339
1400
  this.preferenceCenter = null;
1340
1401
  this.floatingWidget = null;
1341
1402
  this.gtmIntegration = null;
1403
+ this.hideTimeout = null;
1404
+ // SSR guard
1405
+ if (typeof window === 'undefined') {
1406
+ this.config = config;
1407
+ this.consentManager = null;
1408
+ this.storageManager = null;
1409
+ this.eventEmitter = null;
1410
+ this.scriptBlocker = null;
1411
+ return;
1412
+ }
1342
1413
  this.config = this.validateConfig(config);
1343
1414
  this.consentManager = new ConsentManager(this.config);
1344
1415
  this.storageManager = new StorageManager();
@@ -1347,25 +1418,32 @@ class CookieConsent {
1347
1418
  if (this.config.gtmConsentMode) {
1348
1419
  this.gtmIntegration = new GTMConsentMode(new DataLayerManager(), this.config);
1349
1420
  }
1350
- // Listen for preference center requests
1351
- this.eventEmitter.on('preferences:show', () => {
1352
- this.showPreferences();
1353
- });
1354
- // Listen for consent updates
1421
+ // Listen for consent events — callbacks are fired AFTER consent is persisted
1355
1422
  this.eventEmitter.on('consent:accept', (categories) => {
1423
+ var _a, _b;
1356
1424
  this.updateConsent(categories);
1425
+ (_b = (_a = this.config).onAccept) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1357
1426
  });
1358
1427
  this.eventEmitter.on('consent:reject', (categories) => {
1428
+ var _a, _b;
1359
1429
  this.updateConsent(categories);
1430
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1360
1431
  });
1361
1432
  this.eventEmitter.on('consent:update', (categories) => {
1433
+ var _a, _b;
1362
1434
  this.updateConsent(categories);
1435
+ (_b = (_a = this.config).onChange) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1436
+ });
1437
+ this.eventEmitter.on('preferences:show', () => {
1438
+ this.showPreferences();
1363
1439
  });
1364
1440
  }
1365
1441
  /**
1366
1442
  * Initialize the cookie consent system
1367
1443
  */
1368
1444
  init() {
1445
+ if (typeof window === 'undefined')
1446
+ return;
1369
1447
  // 1. Start blocking scripts immediately
1370
1448
  this.scriptBlocker.init();
1371
1449
  // 2. Set GTM default consent BEFORE checking storage
@@ -1375,35 +1453,28 @@ class CookieConsent {
1375
1453
  // 3. Check for existing consent
1376
1454
  const storedConsent = this.storageManager.load();
1377
1455
  if (storedConsent && !this.storageManager.isExpired(storedConsent)) {
1378
- // Valid consent exists
1379
1456
  if (this.consentManager.needsUpdate(storedConsent)) {
1380
- // Policy updated, show banner again
1381
1457
  if (this.config.autoShow) {
1382
1458
  this.showBanner();
1383
1459
  }
1384
1460
  }
1385
1461
  else {
1386
- // Apply stored consent
1387
1462
  this.applyConsent(storedConsent.categories);
1388
- // Restore GTM consent for returning visitors (within wait_for_update window)
1389
1463
  if (this.gtmIntegration) {
1390
1464
  this.gtmIntegration.updateConsent(storedConsent.categories);
1391
1465
  }
1392
1466
  this.eventEmitter.emit('consent:load', storedConsent);
1393
- // Show floating widget if enabled
1394
1467
  if (this.config.showWidget) {
1395
1468
  this.showFloatingWidget();
1396
1469
  }
1397
1470
  }
1398
1471
  }
1399
1472
  else {
1400
- // No consent or expired
1401
1473
  if (this.config.autoShow) {
1402
1474
  this.showBanner();
1403
1475
  }
1404
1476
  }
1405
- // Store instance globally so it won't be garbage collected
1406
- // when used without a variable (e.g. new CookieConsent({}).init())
1477
+ // Store instance globally
1407
1478
  window.cookieConsent = this;
1408
1479
  this.eventEmitter.emit('consent:init');
1409
1480
  }
@@ -1426,14 +1497,18 @@ class CookieConsent {
1426
1497
  showPreferences() {
1427
1498
  var _a;
1428
1499
  const stored = (_a = this.storageManager.load()) === null || _a === void 0 ? void 0 : _a.categories;
1429
- // Default to all ON when no prior consent (user chose to customize)
1500
+ // Default to all ON when no prior consent
1430
1501
  const currentConsent = stored || {
1431
1502
  necessary: true,
1432
1503
  analytics: true,
1433
1504
  marketing: true,
1434
- preferences: true,
1435
1505
  };
1436
- // Always recreate to get fresh state from storage
1506
+ // Add any configured categories not in current consent
1507
+ for (const key of Object.keys(this.config.categories)) {
1508
+ if (!(key in currentConsent)) {
1509
+ currentConsent[key] = key === 'necessary';
1510
+ }
1511
+ }
1437
1512
  if (this.preferenceCenter) {
1438
1513
  this.preferenceCenter.destroy();
1439
1514
  }
@@ -1450,11 +1525,11 @@ class CookieConsent {
1450
1525
  if (this.gtmIntegration) {
1451
1526
  this.gtmIntegration.updateConsent(categories);
1452
1527
  }
1453
- // Show floating widget after consent is given (delay to let banner hide)
1528
+ // Show floating widget after consent is given
1454
1529
  if (this.config.showWidget) {
1455
1530
  setTimeout(() => {
1456
1531
  this.showFloatingWidget();
1457
- }, 400); // Wait for banner hide animation
1532
+ }, 400);
1458
1533
  }
1459
1534
  }
1460
1535
  /**
@@ -1467,14 +1542,19 @@ class CookieConsent {
1467
1542
  * Reset consent (clear stored data and show banner)
1468
1543
  */
1469
1544
  reset() {
1545
+ var _a, _b;
1470
1546
  this.storageManager.clear();
1471
1547
  this.scriptBlocker.block();
1472
- // Clear all non-essential cookies on reset
1473
- clearDeniedCookies({ necessary: true, analytics: false, marketing: false, preferences: false });
1474
- // Update GTM to denied state
1548
+ const denied = { necessary: true, analytics: false, marketing: false };
1549
+ for (const key of Object.keys(this.config.categories)) {
1550
+ if (key !== 'necessary')
1551
+ denied[key] = false;
1552
+ }
1553
+ clearDeniedCookies(denied);
1475
1554
  if (this.gtmIntegration) {
1476
- this.gtmIntegration.updateConsent({ necessary: true, analytics: false, marketing: false });
1555
+ this.gtmIntegration.updateConsent(denied);
1477
1556
  }
1557
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1478
1558
  this.showBanner();
1479
1559
  }
1480
1560
  /**
@@ -1494,6 +1574,10 @@ class CookieConsent {
1494
1574
  */
1495
1575
  destroy() {
1496
1576
  var _a, _b, _c, _d;
1577
+ if (this.hideTimeout) {
1578
+ clearTimeout(this.hideTimeout);
1579
+ this.hideTimeout = null;
1580
+ }
1497
1581
  (_a = this.banner) === null || _a === void 0 ? void 0 : _a.destroy();
1498
1582
  this.banner = null;
1499
1583
  (_b = this.preferenceCenter) === null || _b === void 0 ? void 0 : _b.destroy();
@@ -1501,6 +1585,10 @@ class CookieConsent {
1501
1585
  (_c = this.floatingWidget) === null || _c === void 0 ? void 0 : _c.destroy();
1502
1586
  this.floatingWidget = null;
1503
1587
  (_d = this.scriptBlocker) === null || _d === void 0 ? void 0 : _d.destroy();
1588
+ this.eventEmitter.clear();
1589
+ if (window.cookieConsent === this) {
1590
+ window.cookieConsent = undefined;
1591
+ }
1504
1592
  }
1505
1593
  /**
1506
1594
  * Show the banner
@@ -1526,7 +1614,6 @@ class CookieConsent {
1526
1614
  */
1527
1615
  applyConsent(categories) {
1528
1616
  this.scriptBlocker.unblock(categories);
1529
- // CNIL/GDPR: actively delete cookies for denied categories
1530
1617
  clearDeniedCookies(categories);
1531
1618
  }
1532
1619
  /**
@@ -1537,22 +1624,22 @@ class CookieConsent {
1537
1624
  necessary: {
1538
1625
  enabled: true,
1539
1626
  readOnly: true,
1540
- label: 'Nécessaires',
1541
- description: 'Ces cookies sont indispensables au fonctionnement du site.',
1627
+ label: 'Essential',
1628
+ description: 'Required for the website to function properly.',
1542
1629
  },
1543
1630
  analytics: {
1544
1631
  enabled: true,
1545
1632
  readOnly: false,
1546
- label: 'Analytiques',
1547
- description: 'Ces cookies nous aident à comprendre comment vous utilisez le site.',
1633
+ label: 'Analytics',
1634
+ description: 'Help us understand how you use our site.',
1548
1635
  },
1549
1636
  marketing: {
1550
1637
  enabled: true,
1551
1638
  readOnly: false,
1552
1639
  label: 'Marketing',
1553
- description: 'Ces cookies sont utilisés pour vous proposer des publicités pertinentes.',
1640
+ description: 'Used to deliver relevant advertisements.',
1554
1641
  },
1555
- }, mode: config.mode || 'opt-in', autoShow: config.autoShow !== undefined ? config.autoShow : true, revision: config.revision || 1, gtmConsentMode: config.gtmConsentMode !== undefined ? config.gtmConsentMode : true, disablePageInteraction: config.disablePageInteraction || false, theme: config.theme || 'light', position: config.position || 'bottom-left', layout: config.layout || 'box', backdropBlur: config.backdropBlur !== false, animationStyle: config.animationStyle || 'smooth', preferencesPosition: config.preferencesPosition || 'center', showWidget: config.showWidget !== undefined ? config.showWidget : true, widgetPosition: config.widgetPosition || 'bottom-left', widgetStyle: config.widgetStyle || 'compact' });
1642
+ }, mode: config.mode || 'opt-in', autoShow: config.autoShow !== undefined ? config.autoShow : true, revision: config.revision || 1, gtmConsentMode: config.gtmConsentMode || false, disablePageInteraction: config.disablePageInteraction || false, theme: config.theme || 'light', position: config.position || 'bottom-left', layout: config.layout || 'box', backdropBlur: config.backdropBlur !== false, animationStyle: config.animationStyle || 'smooth', preferencesPosition: config.preferencesPosition || 'center', showWidget: config.showWidget !== undefined ? config.showWidget : true, widgetPosition: config.widgetPosition || 'bottom-left', widgetStyle: config.widgetStyle || 'compact' });
1556
1643
  }
1557
1644
  }
1558
1645