cookiecraft 1.0.7 → 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.
@@ -49,13 +49,20 @@
49
49
  * Clear consent record from localStorage
50
50
  */
51
51
  clear() {
52
- localStorage.removeItem(StorageManager.STORAGE_KEY);
52
+ try {
53
+ localStorage.removeItem(StorageManager.STORAGE_KEY);
54
+ }
55
+ catch (e) {
56
+ console.error('Failed to clear consent:', e);
57
+ }
53
58
  }
54
59
  /**
55
60
  * Check if consent record has expired
56
61
  */
57
62
  isExpired(consent) {
58
63
  const expiry = new Date(consent.expiresAt);
64
+ if (isNaN(expiry.getTime()))
65
+ return true;
59
66
  return expiry < new Date();
60
67
  }
61
68
  /**
@@ -66,7 +73,6 @@
66
73
  typeof data.version === 'number' &&
67
74
  typeof data.timestamp === 'string' &&
68
75
  typeof data.categories === 'object' &&
69
- typeof data.userAgent === 'string' &&
70
76
  typeof data.expiresAt === 'string');
71
77
  }
72
78
  /**
@@ -82,11 +88,20 @@
82
88
  const now = new Date();
83
89
  const expiryDate = new Date(now);
84
90
  expiryDate.setMonth(expiryDate.getMonth() + StorageManager.EXPIRY_MONTHS);
91
+ // Coerce category values to booleans
92
+ const rawCategories = record.categories;
93
+ const categories = {
94
+ necessary: rawCategories.necessary === true,
95
+ analytics: rawCategories.analytics === true,
96
+ marketing: rawCategories.marketing === true,
97
+ };
98
+ if ('preferences' in rawCategories) {
99
+ categories.preferences = rawCategories.preferences === true;
100
+ }
85
101
  return {
86
102
  version: typeof record.version === 'number' ? record.version : 1,
87
103
  timestamp: typeof record.timestamp === 'string' ? record.timestamp : now.toISOString(),
88
- categories: record.categories,
89
- userAgent: typeof record.userAgent === 'string' ? record.userAgent : navigator.userAgent,
104
+ categories,
90
105
  expiresAt: typeof record.expiresAt === 'string' ? record.expiresAt : expiryDate.toISOString(),
91
106
  };
92
107
  }
@@ -101,6 +116,7 @@
101
116
  */
102
117
  class ConsentManager {
103
118
  constructor(config) {
119
+ this.consent = null;
104
120
  this.config = config;
105
121
  }
106
122
  /**
@@ -119,6 +135,10 @@
119
135
  }
120
136
  }
121
137
  }
138
+ // Coerce all values to booleans
139
+ for (const key of Object.keys(categories)) {
140
+ categories[key] = categories[key] === true;
141
+ }
122
142
  return true;
123
143
  }
124
144
  /**
@@ -135,13 +155,12 @@
135
155
  * Check if user needs to give consent
136
156
  */
137
157
  needsConsent() {
138
- return this.consent === undefined;
158
+ return this.consent === null;
139
159
  }
140
160
  /**
141
161
  * Check if stored consent needs update due to policy change
142
162
  */
143
163
  needsUpdate(storedConsent) {
144
- // Check if policy version has changed
145
164
  return storedConsent.version < this.config.revision;
146
165
  }
147
166
  /**
@@ -161,7 +180,6 @@
161
180
  version: this.config.revision,
162
181
  timestamp: now.toISOString(),
163
182
  categories: Object.assign({}, categories),
164
- userAgent: navigator.userAgent,
165
183
  expiresAt: expiryDate.toISOString(),
166
184
  };
167
185
  }
@@ -402,7 +420,6 @@
402
420
  class CategoryManager {
403
421
  constructor() {
404
422
  this.categories = new Map();
405
- // Initialize with common patterns
406
423
  this.initializeDefaultPatterns();
407
424
  }
408
425
  /**
@@ -439,11 +456,11 @@
439
456
  }
440
457
  /**
441
458
  * Initialize default URL patterns for common tracking services
459
+ * Note: GTM is NOT auto-categorized — it should be managed via GTM Consent Mode v2
442
460
  */
443
461
  initializeDefaultPatterns() {
444
462
  this.categories.set('analytics', [
445
463
  'google-analytics.com',
446
- 'googletagmanager.com',
447
464
  'analytics.google.com',
448
465
  'plausible.io',
449
466
  'matomo.org',
@@ -453,6 +470,7 @@
453
470
  'amplitude.com',
454
471
  ]);
455
472
  this.categories.set('marketing', [
473
+ 'googletagmanager.com',
456
474
  'facebook.net',
457
475
  'facebook.com/tr',
458
476
  'connect.facebook.net',
@@ -464,6 +482,7 @@
464
482
  'adroll.com',
465
483
  'taboola.com',
466
484
  'outbrain.com',
485
+ 'tiktok.com',
467
486
  ]);
468
487
  this.categories.set('necessary', []);
469
488
  }
@@ -514,18 +533,47 @@
514
533
  // Allow hsl/hsla
515
534
  if (/^hsla?\(\s*[\d\s,./%deg]+\)$/.test(trimmed))
516
535
  return trimmed;
517
- // Allow CSS named colors (basic set)
518
- if (/^[a-zA-Z]+$/.test(trimmed))
536
+ // Allow CSS named colors (basic set) but block CSS keywords that could be abused
537
+ const CSS_KEYWORDS = ['inherit', 'initial', 'unset', 'revert', 'revert-layer'];
538
+ if (/^[a-zA-Z]+$/.test(trimmed) && !CSS_KEYWORDS.includes(trimmed.toLowerCase())) {
519
539
  return trimmed;
540
+ }
520
541
  return '';
521
542
  }
522
543
 
544
+ /**
545
+ * Normalize any supported color format to 6-digit hex
546
+ * Supports: #RGB, #RRGGBB, #RRGGBBAA, named colors
547
+ * Returns null if conversion fails
548
+ */
549
+ function normalizeToHex6(color) {
550
+ const trimmed = color.trim();
551
+ // Already 6-digit hex
552
+ if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
553
+ return trimmed;
554
+ }
555
+ // 3-digit hex → expand to 6-digit
556
+ if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
557
+ const r = trimmed[1];
558
+ const g = trimmed[2];
559
+ const b = trimmed[3];
560
+ return `#${r}${r}${g}${g}${b}${b}`;
561
+ }
562
+ // 8-digit hex (with alpha) → strip alpha
563
+ if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) {
564
+ return trimmed.substring(0, 7);
565
+ }
566
+ return null;
567
+ }
523
568
  /**
524
569
  * Adjust a hex color brightness by a percentage
525
570
  * Negative = darker, positive = lighter
526
571
  */
527
572
  function adjustColorBrightness(color, percent) {
528
- const hex = color.replace('#', '');
573
+ const hex6 = normalizeToHex6(color);
574
+ if (!hex6)
575
+ return color;
576
+ const hex = hex6.replace('#', '');
529
577
  const r = parseInt(hex.substring(0, 2), 16);
530
578
  const g = parseInt(hex.substring(2, 4), 16);
531
579
  const b = parseInt(hex.substring(4, 6), 16);
@@ -545,8 +593,13 @@
545
593
  function buildColorStyle(safeColor) {
546
594
  if (!safeColor)
547
595
  return '';
548
- const hover = adjustColorBrightness(safeColor, -15);
549
- return `--cc-primary: ${safeColor}; --cc-primary-hover: ${hover};`;
596
+ // Only generate hover color for hex colors
597
+ const hex6 = normalizeToHex6(safeColor);
598
+ if (!hex6) {
599
+ return `--cc-primary: ${safeColor};`;
600
+ }
601
+ const hover = adjustColorBrightness(hex6, -15);
602
+ return `--cc-primary: ${hex6}; --cc-primary-hover: ${hover};`;
550
603
  }
551
604
 
552
605
  /**
@@ -555,6 +608,8 @@
555
608
  class Banner {
556
609
  constructor(config, eventEmitter) {
557
610
  this.element = null;
611
+ this.hideTimeout = null;
612
+ this.previousActiveElement = null;
558
613
  this.config = config;
559
614
  this.eventEmitter = eventEmitter;
560
615
  }
@@ -562,8 +617,14 @@
562
617
  * Show the banner
563
618
  */
564
619
  show() {
620
+ // Clear any pending hide timeout
621
+ if (this.hideTimeout) {
622
+ clearTimeout(this.hideTimeout);
623
+ this.hideTimeout = null;
624
+ }
565
625
  const append = () => {
566
626
  if (!this.element) {
627
+ this.previousActiveElement = document.activeElement;
567
628
  this.element = this.createDOM();
568
629
  document.body.appendChild(this.element);
569
630
  this.attachListeners();
@@ -576,9 +637,9 @@
576
637
  // Disable page interaction if configured
577
638
  if (this.config.disablePageInteraction) {
578
639
  document.body.style.overflow = 'hidden';
640
+ this.trapFocus();
579
641
  }
580
642
  };
581
- // Wait for body if not yet available
582
643
  if (!document.body) {
583
644
  document.addEventListener('DOMContentLoaded', append);
584
645
  return;
@@ -591,22 +652,30 @@
591
652
  hide() {
592
653
  var _a;
593
654
  (_a = this.element) === null || _a === void 0 ? void 0 : _a.classList.remove('is-visible');
594
- // Re-enable page interaction
595
655
  if (this.config.disablePageInteraction) {
596
656
  document.body.style.overflow = '';
597
657
  }
598
- setTimeout(() => {
658
+ this.hideTimeout = setTimeout(() => {
599
659
  this.destroy();
600
- }, 300); // Match CSS transition
660
+ }, 300);
601
661
  }
602
662
  /**
603
663
  * Destroy the banner
604
664
  */
605
665
  destroy() {
666
+ if (this.hideTimeout) {
667
+ clearTimeout(this.hideTimeout);
668
+ this.hideTimeout = null;
669
+ }
606
670
  if (this.element) {
607
671
  this.element.remove();
608
672
  this.element = null;
609
673
  }
674
+ // Restore focus
675
+ if (this.previousActiveElement && document.contains(this.previousActiveElement)) {
676
+ this.previousActiveElement.focus();
677
+ this.previousActiveElement = null;
678
+ }
610
679
  }
611
680
  /**
612
681
  * Create DOM structure for banner
@@ -617,12 +686,14 @@
617
686
  const position = this.config.position || 'bottom';
618
687
  const layout = this.config.layout || 'bar';
619
688
  const backdropBlur = this.config.backdropBlur !== false;
689
+ const isModal = this.config.disablePageInteraction;
620
690
  const safeColor = this.config.primaryColor ? sanitizeColor(this.config.primaryColor) : '';
621
691
  const colorStyle = buildColorStyle(safeColor);
622
692
  const template = `
623
693
  <div
624
694
  class="cc-banner cc-banner--${escapeHtml(position)} cc-banner--${escapeHtml(layout)} ${backdropBlur ? 'cc-backdrop-blur' : ''}"
625
- role="region"
695
+ role="${isModal ? 'dialog' : 'region'}"
696
+ ${isModal ? 'aria-modal="true"' : ''}
626
697
  aria-label="Cookie consent"
627
698
  aria-live="polite"
628
699
  data-theme="${escapeHtml(theme)}"
@@ -631,7 +702,7 @@
631
702
  <div class="cc-banner__container">
632
703
  <div class="cc-banner__content">
633
704
  <h2 class="cc-banner__title">
634
- ${escapeHtml(translations.title || '🍪 Nous utilisons des cookies')}
705
+ ${escapeHtml(translations.title || 'We use cookies')}
635
706
  </h2>
636
707
  <p class="cc-banner__description">
637
708
  ${this.getDescriptionHTML()}
@@ -641,23 +712,23 @@
641
712
  <button
642
713
  class="cc-btn cc-btn--ghost"
643
714
  data-action="reject"
644
- aria-label="${escapeHtml(translations.rejectAll || 'Uniquement essentiels')}"
715
+ aria-label="${escapeHtml(translations.rejectAll || 'Essentials only')}"
645
716
  >
646
- ${escapeHtml(translations.rejectAll || 'Uniquement essentiels')}
717
+ ${escapeHtml(translations.rejectAll || 'Essentials only')}
647
718
  </button>
648
719
  <button
649
720
  class="cc-btn cc-btn--tertiary"
650
721
  data-action="customize"
651
- aria-label="${escapeHtml(translations.customize || 'Personnaliser')}"
722
+ aria-label="${escapeHtml(translations.customize || 'Customize')}"
652
723
  >
653
- ${escapeHtml(translations.customize || 'Personnaliser')}
724
+ ${escapeHtml(translations.customize || 'Customize')}
654
725
  </button>
655
726
  <button
656
727
  class="cc-btn cc-btn--accept"
657
728
  data-action="accept"
658
- aria-label="${escapeHtml(translations.acceptAll || 'Tout accepter')}"
729
+ aria-label="${escapeHtml(translations.acceptAll || 'Accept all')}"
659
730
  >
660
- ${escapeHtml(translations.acceptAll || 'Tout accepter')}
731
+ ${escapeHtml(translations.acceptAll || 'Accept all')}
661
732
  </button>
662
733
  </div>
663
734
  </div>
@@ -689,10 +760,8 @@
689
760
  break;
690
761
  }
691
762
  });
692
- // Keyboard support
693
763
  (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
694
764
  if (e.key === 'Escape' && this.config.disablePageInteraction) {
695
- // Allow ESC to close if page interaction is disabled
696
765
  this.handleRejectAll();
697
766
  }
698
767
  });
@@ -701,36 +770,24 @@
701
770
  * Handle accept all action
702
771
  */
703
772
  handleAcceptAll() {
704
- var _a, _b, _c;
705
- const allCategories = {
706
- necessary: true,
707
- analytics: true,
708
- marketing: true,
709
- };
710
- // Only add preferences if it's configured
711
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
712
- allCategories.preferences = true;
773
+ const allCategories = { necessary: true, analytics: true, marketing: true };
774
+ // Add all configured categories
775
+ for (const key of Object.keys(this.config.categories)) {
776
+ allCategories[key] = true;
713
777
  }
714
778
  this.eventEmitter.emit('consent:accept', allCategories);
715
- (_c = (_b = this.config).onAccept) === null || _c === void 0 ? void 0 : _c.call(_b, allCategories);
716
779
  this.hide();
717
780
  }
718
781
  /**
719
782
  * Handle reject all action
720
783
  */
721
784
  handleRejectAll() {
722
- var _a, _b, _c;
723
- const necessaryOnly = {
724
- necessary: true,
725
- analytics: false,
726
- marketing: false,
727
- };
728
- // Only add preferences if it's configured
729
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
730
- necessaryOnly.preferences = false;
785
+ const necessaryOnly = { necessary: true, analytics: false, marketing: false };
786
+ for (const key of Object.keys(this.config.categories)) {
787
+ if (key !== 'necessary')
788
+ necessaryOnly[key] = false;
731
789
  }
732
790
  this.eventEmitter.emit('consent:reject', necessaryOnly);
733
- (_c = (_b = this.config).onReject) === null || _c === void 0 ? void 0 : _c.call(_b);
734
791
  this.hide();
735
792
  }
736
793
  /**
@@ -740,17 +797,41 @@
740
797
  this.eventEmitter.emit('preferences:show');
741
798
  this.hide();
742
799
  }
800
+ /**
801
+ * Trap focus within banner (when disablePageInteraction is true)
802
+ */
803
+ trapFocus() {
804
+ var _a, _b;
805
+ const focusableElements = (_a = this.element) === null || _a === void 0 ? void 0 : _a.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
806
+ if (!focusableElements || focusableElements.length === 0)
807
+ return;
808
+ const firstFocusable = focusableElements[0];
809
+ const lastFocusable = focusableElements[focusableElements.length - 1];
810
+ firstFocusable === null || firstFocusable === void 0 ? void 0 : firstFocusable.focus();
811
+ (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
812
+ if (e.key === 'Tab') {
813
+ if (e.shiftKey && document.activeElement === firstFocusable) {
814
+ e.preventDefault();
815
+ lastFocusable.focus();
816
+ }
817
+ else if (!e.shiftKey && document.activeElement === lastFocusable) {
818
+ e.preventDefault();
819
+ firstFocusable.focus();
820
+ }
821
+ }
822
+ });
823
+ }
743
824
  /**
744
825
  * Generate description HTML with privacy policy link
745
826
  */
746
827
  getDescriptionHTML() {
747
828
  const translations = this.config.translations || {};
748
- const defaultDescription = 'Pour améliorer votre expérience sur notre site, nous utilisons des cookies. Vous pouvez choisir les cookies que vous acceptez.';
829
+ const defaultDescription = 'We use cookies to improve your experience on our site. You can choose which cookies you accept.';
749
830
  const description = escapeHtml(translations.description || defaultDescription);
750
831
  if (translations.privacyPolicyUrl) {
751
832
  const safeUrl = sanitizeUrl(translations.privacyPolicyUrl);
752
833
  if (safeUrl) {
753
- const linkLabel = escapeHtml(translations.privacyPolicyLabel || 'Politique de confidentialité');
834
+ const linkLabel = escapeHtml(translations.privacyPolicyLabel || 'Privacy Policy');
754
835
  return `${description} <a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkLabel}</a>`;
755
836
  }
756
837
  }
@@ -764,6 +845,7 @@
764
845
  class PreferenceCenter {
765
846
  constructor(config, eventEmitter, currentConsent) {
766
847
  this.element = null;
848
+ this.previousActiveElement = null;
767
849
  this.config = config;
768
850
  this.eventEmitter = eventEmitter;
769
851
  this.currentConsent = currentConsent;
@@ -774,13 +856,13 @@
774
856
  show() {
775
857
  const append = () => {
776
858
  if (!this.element) {
859
+ this.previousActiveElement = document.activeElement;
777
860
  this.element = this.createDOM();
778
861
  document.body.appendChild(this.element);
779
862
  this.attachListeners();
780
863
  }
781
864
  this.element.classList.add('is-visible');
782
865
  this.trapFocus();
783
- // Prevent body scroll
784
866
  document.body.style.overflow = 'hidden';
785
867
  };
786
868
  if (!document.body) {
@@ -796,6 +878,11 @@
796
878
  var _a;
797
879
  (_a = this.element) === null || _a === void 0 ? void 0 : _a.classList.remove('is-visible');
798
880
  document.body.style.overflow = '';
881
+ // Restore focus to triggering element
882
+ if (this.previousActiveElement && document.contains(this.previousActiveElement)) {
883
+ this.previousActiveElement.focus();
884
+ this.previousActiveElement = null;
885
+ }
799
886
  setTimeout(() => {
800
887
  this.destroy();
801
888
  }, 300);
@@ -830,7 +917,7 @@
830
917
  <polyline points="15 3 21 3 21 9"/>
831
918
  <line x1="10" y1="14" x2="21" y2="3"/>
832
919
  </svg>
833
- ${escapeHtml(translations.privacyPolicyLabel || 'Politique de confidentialité')}
920
+ ${escapeHtml(translations.privacyPolicyLabel || 'Privacy Policy')}
834
921
  </a>
835
922
  `;
836
923
  })()
@@ -848,7 +935,7 @@
848
935
  <div class="cc-modal__content">
849
936
  <div class="cc-modal__header">
850
937
  <h2 id="cc-modal-title">
851
- ${escapeHtml(translations.preferencesTitle || translations.title || 'Préférences de cookies')}
938
+ ${escapeHtml(translations.preferencesTitle || translations.title || 'Cookie Preferences')}
852
939
  </h2>
853
940
  </div>
854
941
 
@@ -865,13 +952,13 @@
865
952
  class="cc-btn cc-btn--secondary"
866
953
  data-action="reject"
867
954
  >
868
- ${escapeHtml(translations.essentialsOnly || 'Uniquement les essentiels')}
955
+ ${escapeHtml(translations.essentialsOnly || 'Essentials only')}
869
956
  </button>
870
957
  <button
871
958
  class="cc-btn cc-btn--primary"
872
959
  data-action="save"
873
960
  >
874
- ${escapeHtml(translations.savePreferences || 'Enregistrer mes choix')}
961
+ ${escapeHtml(translations.savePreferences || 'Save preferences')}
875
962
  </button>
876
963
  </div>
877
964
  </div>
@@ -889,7 +976,7 @@
889
976
  const categories = Object.entries(this.config.categories);
890
977
  return categories
891
978
  .map(([key, config]) => {
892
- const checked = this.currentConsent[key];
979
+ const checked = this.currentConsent[key] === true;
893
980
  const disabled = config.readOnly;
894
981
  return `
895
982
  <div class="cc-category">
@@ -936,13 +1023,16 @@
936
1023
  * Handle save preferences
937
1024
  */
938
1025
  handleSave() {
939
- var _a, _b, _c;
1026
+ var _a;
940
1027
  const checkboxes = (_a = this.element) === null || _a === void 0 ? void 0 : _a.querySelectorAll('input[data-category]');
941
- const categories = {
942
- necessary: true,
943
- analytics: false,
944
- marketing: false,
945
- };
1028
+ // Initialize all configured categories to false
1029
+ const categories = { necessary: true, analytics: false, marketing: false };
1030
+ for (const key of Object.keys(this.config.categories)) {
1031
+ if (key !== 'necessary') {
1032
+ categories[key] = false;
1033
+ }
1034
+ }
1035
+ // Override with actual checkbox values
946
1036
  checkboxes === null || checkboxes === void 0 ? void 0 : checkboxes.forEach((checkbox) => {
947
1037
  if (checkbox instanceof HTMLInputElement) {
948
1038
  const category = checkbox.getAttribute('data-category');
@@ -952,22 +1042,16 @@
952
1042
  }
953
1043
  });
954
1044
  this.eventEmitter.emit('consent:update', categories);
955
- (_c = (_b = this.config).onChange) === null || _c === void 0 ? void 0 : _c.call(_b, categories);
956
1045
  this.hide();
957
1046
  }
958
1047
  /**
959
1048
  * Handle reject all
960
1049
  */
961
1050
  handleRejectAll() {
962
- var _a;
963
- const necessaryOnly = {
964
- necessary: true,
965
- analytics: false,
966
- marketing: false,
967
- };
968
- // Only add preferences if it's configured
969
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
970
- necessaryOnly.preferences = false;
1051
+ const necessaryOnly = { necessary: true, analytics: false, marketing: false };
1052
+ for (const key of Object.keys(this.config.categories)) {
1053
+ if (key !== 'necessary')
1054
+ necessaryOnly[key] = false;
971
1055
  }
972
1056
  this.eventEmitter.emit('consent:reject', necessaryOnly);
973
1057
  this.hide();
@@ -982,9 +1066,7 @@
982
1066
  return;
983
1067
  const firstFocusable = focusableElements[0];
984
1068
  const lastFocusable = focusableElements[focusableElements.length - 1];
985
- // Focus first element
986
1069
  firstFocusable === null || firstFocusable === void 0 ? void 0 : firstFocusable.focus();
987
- // Trap focus
988
1070
  (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
989
1071
  if (e.key === 'Tab') {
990
1072
  if (e.shiftKey && document.activeElement === firstFocusable) {
@@ -1072,7 +1154,7 @@
1072
1154
  <div
1073
1155
  class="cc-widget cc-widget--${escapeHtml(widgetPosition)} cc-widget--${escapeHtml(widgetStyle)}"
1074
1156
  role="button"
1075
- aria-label="${escapeHtml(translations.cookieSettings || 'Paramètres des cookies')}"
1157
+ aria-label="${escapeHtml(translations.cookieSettings || 'Cookie settings')}"
1076
1158
  tabindex="0"
1077
1159
  data-theme="${escapeHtml(theme)}"
1078
1160
  style="${colorStyle}"
@@ -1123,11 +1205,6 @@
1123
1205
 
1124
1206
  /**
1125
1207
  * GTMConsentMode - Full integration with Google Consent Mode v2
1126
- *
1127
- * Implements all required signals:
1128
- * - ad_storage, ad_user_data, ad_personalization, analytics_storage (core GCM v2)
1129
- * - functionality_storage, personalization_storage, security_storage (non-core)
1130
- * - wait_for_update, url_passthrough, ads_data_redaction (advanced features)
1131
1208
  */
1132
1209
  class GTMConsentMode {
1133
1210
  constructor(dataLayerManager, config) {
@@ -1147,15 +1224,13 @@
1147
1224
  analytics_storage: 'denied',
1148
1225
  functionality_storage: 'denied',
1149
1226
  personalization_storage: 'denied',
1150
- security_storage: 'granted', // Always granted
1227
+ security_storage: 'granted',
1151
1228
  };
1152
- // Add wait_for_update to give CMP time to restore returning visitor consent
1153
1229
  const waitForUpdate = (_a = this.config.gtmWaitForUpdate) !== null && _a !== void 0 ? _a : 500;
1154
1230
  if (waitForUpdate > 0) {
1155
1231
  defaults['wait_for_update'] = waitForUpdate;
1156
1232
  }
1157
1233
  this.dataLayerManager.pushConsent('default', defaults);
1158
- // Set advanced features via gtag('set', ...)
1159
1234
  if (this.config.gtmUrlPassthrough) {
1160
1235
  this.dataLayerManager.pushSet('url_passthrough', true);
1161
1236
  }
@@ -1165,7 +1240,6 @@
1165
1240
  }
1166
1241
  /**
1167
1242
  * Update consent state based on user choices
1168
- * Called both on new consent and on page load for returning visitors
1169
1243
  */
1170
1244
  updateConsent(categories) {
1171
1245
  const gtmConsent = this.mapCategoriesToGTM(categories);
@@ -1175,14 +1249,19 @@
1175
1249
  * Map consent categories to GTM Consent Mode v2 format
1176
1250
  */
1177
1251
  mapCategoriesToGTM(categories) {
1252
+ // When preferences category is not configured, default functionality to granted
1253
+ const hasPreferencesCategory = 'preferences' in this.config.categories;
1254
+ const preferencesGranted = hasPreferencesCategory
1255
+ ? categories.preferences === true
1256
+ : true;
1178
1257
  return {
1179
1258
  ad_storage: categories.marketing ? 'granted' : 'denied',
1180
1259
  ad_user_data: categories.marketing ? 'granted' : 'denied',
1181
1260
  ad_personalization: categories.marketing ? 'granted' : 'denied',
1182
1261
  analytics_storage: categories.analytics ? 'granted' : 'denied',
1183
- functionality_storage: categories.preferences ? 'granted' : 'denied',
1184
- personalization_storage: categories.preferences ? 'granted' : 'denied',
1185
- security_storage: 'granted', // Always granted
1262
+ functionality_storage: preferencesGranted ? 'granted' : 'denied',
1263
+ personalization_storage: preferencesGranted ? 'granted' : 'denied',
1264
+ security_storage: 'granted',
1186
1265
  };
1187
1266
  }
1188
1267
  }
@@ -1327,6 +1406,16 @@
1327
1406
  this.preferenceCenter = null;
1328
1407
  this.floatingWidget = null;
1329
1408
  this.gtmIntegration = null;
1409
+ this.hideTimeout = null;
1410
+ // SSR guard
1411
+ if (typeof window === 'undefined') {
1412
+ this.config = config;
1413
+ this.consentManager = null;
1414
+ this.storageManager = null;
1415
+ this.eventEmitter = null;
1416
+ this.scriptBlocker = null;
1417
+ return;
1418
+ }
1330
1419
  this.config = this.validateConfig(config);
1331
1420
  this.consentManager = new ConsentManager(this.config);
1332
1421
  this.storageManager = new StorageManager();
@@ -1335,25 +1424,32 @@
1335
1424
  if (this.config.gtmConsentMode) {
1336
1425
  this.gtmIntegration = new GTMConsentMode(new DataLayerManager(), this.config);
1337
1426
  }
1338
- // Listen for preference center requests
1339
- this.eventEmitter.on('preferences:show', () => {
1340
- this.showPreferences();
1341
- });
1342
- // Listen for consent updates
1427
+ // Listen for consent events — callbacks are fired AFTER consent is persisted
1343
1428
  this.eventEmitter.on('consent:accept', (categories) => {
1429
+ var _a, _b;
1344
1430
  this.updateConsent(categories);
1431
+ (_b = (_a = this.config).onAccept) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1345
1432
  });
1346
1433
  this.eventEmitter.on('consent:reject', (categories) => {
1434
+ var _a, _b;
1347
1435
  this.updateConsent(categories);
1436
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1348
1437
  });
1349
1438
  this.eventEmitter.on('consent:update', (categories) => {
1439
+ var _a, _b;
1350
1440
  this.updateConsent(categories);
1441
+ (_b = (_a = this.config).onChange) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1442
+ });
1443
+ this.eventEmitter.on('preferences:show', () => {
1444
+ this.showPreferences();
1351
1445
  });
1352
1446
  }
1353
1447
  /**
1354
1448
  * Initialize the cookie consent system
1355
1449
  */
1356
1450
  init() {
1451
+ if (typeof window === 'undefined')
1452
+ return;
1357
1453
  // 1. Start blocking scripts immediately
1358
1454
  this.scriptBlocker.init();
1359
1455
  // 2. Set GTM default consent BEFORE checking storage
@@ -1363,35 +1459,28 @@
1363
1459
  // 3. Check for existing consent
1364
1460
  const storedConsent = this.storageManager.load();
1365
1461
  if (storedConsent && !this.storageManager.isExpired(storedConsent)) {
1366
- // Valid consent exists
1367
1462
  if (this.consentManager.needsUpdate(storedConsent)) {
1368
- // Policy updated, show banner again
1369
1463
  if (this.config.autoShow) {
1370
1464
  this.showBanner();
1371
1465
  }
1372
1466
  }
1373
1467
  else {
1374
- // Apply stored consent
1375
1468
  this.applyConsent(storedConsent.categories);
1376
- // Restore GTM consent for returning visitors (within wait_for_update window)
1377
1469
  if (this.gtmIntegration) {
1378
1470
  this.gtmIntegration.updateConsent(storedConsent.categories);
1379
1471
  }
1380
1472
  this.eventEmitter.emit('consent:load', storedConsent);
1381
- // Show floating widget if enabled
1382
1473
  if (this.config.showWidget) {
1383
1474
  this.showFloatingWidget();
1384
1475
  }
1385
1476
  }
1386
1477
  }
1387
1478
  else {
1388
- // No consent or expired
1389
1479
  if (this.config.autoShow) {
1390
1480
  this.showBanner();
1391
1481
  }
1392
1482
  }
1393
- // Store instance globally so it won't be garbage collected
1394
- // when used without a variable (e.g. new CookieConsent({}).init())
1483
+ // Store instance globally
1395
1484
  window.cookieConsent = this;
1396
1485
  this.eventEmitter.emit('consent:init');
1397
1486
  }
@@ -1414,14 +1503,18 @@
1414
1503
  showPreferences() {
1415
1504
  var _a;
1416
1505
  const stored = (_a = this.storageManager.load()) === null || _a === void 0 ? void 0 : _a.categories;
1417
- // Default to all ON when no prior consent (user chose to customize)
1506
+ // Default to all ON when no prior consent
1418
1507
  const currentConsent = stored || {
1419
1508
  necessary: true,
1420
1509
  analytics: true,
1421
1510
  marketing: true,
1422
- preferences: true,
1423
1511
  };
1424
- // Always recreate to get fresh state from storage
1512
+ // Add any configured categories not in current consent
1513
+ for (const key of Object.keys(this.config.categories)) {
1514
+ if (!(key in currentConsent)) {
1515
+ currentConsent[key] = key === 'necessary';
1516
+ }
1517
+ }
1425
1518
  if (this.preferenceCenter) {
1426
1519
  this.preferenceCenter.destroy();
1427
1520
  }
@@ -1438,11 +1531,11 @@
1438
1531
  if (this.gtmIntegration) {
1439
1532
  this.gtmIntegration.updateConsent(categories);
1440
1533
  }
1441
- // Show floating widget after consent is given (delay to let banner hide)
1534
+ // Show floating widget after consent is given
1442
1535
  if (this.config.showWidget) {
1443
1536
  setTimeout(() => {
1444
1537
  this.showFloatingWidget();
1445
- }, 400); // Wait for banner hide animation
1538
+ }, 400);
1446
1539
  }
1447
1540
  }
1448
1541
  /**
@@ -1455,14 +1548,19 @@
1455
1548
  * Reset consent (clear stored data and show banner)
1456
1549
  */
1457
1550
  reset() {
1551
+ var _a, _b;
1458
1552
  this.storageManager.clear();
1459
1553
  this.scriptBlocker.block();
1460
- // Clear all non-essential cookies on reset
1461
- clearDeniedCookies({ necessary: true, analytics: false, marketing: false, preferences: false });
1462
- // Update GTM to denied state
1554
+ const denied = { necessary: true, analytics: false, marketing: false };
1555
+ for (const key of Object.keys(this.config.categories)) {
1556
+ if (key !== 'necessary')
1557
+ denied[key] = false;
1558
+ }
1559
+ clearDeniedCookies(denied);
1463
1560
  if (this.gtmIntegration) {
1464
- this.gtmIntegration.updateConsent({ necessary: true, analytics: false, marketing: false });
1561
+ this.gtmIntegration.updateConsent(denied);
1465
1562
  }
1563
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1466
1564
  this.showBanner();
1467
1565
  }
1468
1566
  /**
@@ -1482,6 +1580,10 @@
1482
1580
  */
1483
1581
  destroy() {
1484
1582
  var _a, _b, _c, _d;
1583
+ if (this.hideTimeout) {
1584
+ clearTimeout(this.hideTimeout);
1585
+ this.hideTimeout = null;
1586
+ }
1485
1587
  (_a = this.banner) === null || _a === void 0 ? void 0 : _a.destroy();
1486
1588
  this.banner = null;
1487
1589
  (_b = this.preferenceCenter) === null || _b === void 0 ? void 0 : _b.destroy();
@@ -1489,6 +1591,10 @@
1489
1591
  (_c = this.floatingWidget) === null || _c === void 0 ? void 0 : _c.destroy();
1490
1592
  this.floatingWidget = null;
1491
1593
  (_d = this.scriptBlocker) === null || _d === void 0 ? void 0 : _d.destroy();
1594
+ this.eventEmitter.clear();
1595
+ if (window.cookieConsent === this) {
1596
+ window.cookieConsent = undefined;
1597
+ }
1492
1598
  }
1493
1599
  /**
1494
1600
  * Show the banner
@@ -1514,7 +1620,6 @@
1514
1620
  */
1515
1621
  applyConsent(categories) {
1516
1622
  this.scriptBlocker.unblock(categories);
1517
- // CNIL/GDPR: actively delete cookies for denied categories
1518
1623
  clearDeniedCookies(categories);
1519
1624
  }
1520
1625
  /**
@@ -1525,22 +1630,22 @@
1525
1630
  necessary: {
1526
1631
  enabled: true,
1527
1632
  readOnly: true,
1528
- label: 'Nécessaires',
1529
- description: 'Ces cookies sont indispensables au fonctionnement du site.',
1633
+ label: 'Essential',
1634
+ description: 'Required for the website to function properly.',
1530
1635
  },
1531
1636
  analytics: {
1532
1637
  enabled: true,
1533
1638
  readOnly: false,
1534
- label: 'Analytiques',
1535
- description: 'Ces cookies nous aident à comprendre comment vous utilisez le site.',
1639
+ label: 'Analytics',
1640
+ description: 'Help us understand how you use our site.',
1536
1641
  },
1537
1642
  marketing: {
1538
1643
  enabled: true,
1539
1644
  readOnly: false,
1540
1645
  label: 'Marketing',
1541
- description: 'Ces cookies sont utilisés pour vous proposer des publicités pertinentes.',
1646
+ description: 'Used to deliver relevant advertisements.',
1542
1647
  },
1543
- }, 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' });
1648
+ }, 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' });
1544
1649
  }
1545
1650
  }
1546
1651