cookiecraft 1.0.7 → 1.0.9

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
  }
@@ -1318,6 +1397,111 @@
1318
1397
  }
1319
1398
  }
1320
1399
 
1400
+ /**
1401
+ * Built-in translations for supported languages
1402
+ * Users can override any string via config.translations
1403
+ */
1404
+ const en = {
1405
+ title: 'We use cookies',
1406
+ description: 'We use cookies to improve your experience on our site. You can choose which cookies you accept.',
1407
+ acceptAll: 'Accept all',
1408
+ rejectAll: 'Essentials only',
1409
+ customize: 'Customize',
1410
+ savePreferences: 'Save preferences',
1411
+ essentialsOnly: 'Essentials only',
1412
+ preferencesTitle: 'Cookie Preferences',
1413
+ cookieSettings: 'Cookie settings',
1414
+ cookies: 'Cookies',
1415
+ privacyPolicyLabel: 'Privacy Policy',
1416
+ };
1417
+ const fr = {
1418
+ title: 'Nous utilisons des cookies',
1419
+ description: 'Ce site utilise des cookies pour améliorer votre expérience de navigation. Vous pouvez choisir les cookies que vous acceptez.',
1420
+ acceptAll: 'Tout accepter',
1421
+ rejectAll: 'Essentiels uniquement',
1422
+ customize: 'Personnaliser',
1423
+ savePreferences: 'Enregistrer',
1424
+ essentialsOnly: 'Essentiels uniquement',
1425
+ preferencesTitle: 'Préférences des cookies',
1426
+ cookieSettings: 'Paramètres des cookies',
1427
+ cookies: 'Cookies',
1428
+ privacyPolicyLabel: 'Politique de confidentialité',
1429
+ };
1430
+ const de = {
1431
+ title: 'Wir verwenden Cookies',
1432
+ description: 'Diese Website verwendet Cookies, um Ihr Erlebnis zu verbessern. Sie können wählen, welche Cookies Sie akzeptieren.',
1433
+ acceptAll: 'Alle akzeptieren',
1434
+ rejectAll: 'Nur essenzielle',
1435
+ customize: 'Anpassen',
1436
+ savePreferences: 'Speichern',
1437
+ essentialsOnly: 'Nur essenzielle',
1438
+ preferencesTitle: 'Cookie-Einstellungen',
1439
+ cookieSettings: 'Cookie-Einstellungen',
1440
+ cookies: 'Cookies',
1441
+ privacyPolicyLabel: 'Datenschutzrichtlinie',
1442
+ };
1443
+ const es = {
1444
+ title: 'Usamos cookies',
1445
+ description: 'Este sitio utiliza cookies para mejorar su experiencia. Puede elegir qué cookies acepta.',
1446
+ acceptAll: 'Aceptar todo',
1447
+ rejectAll: 'Solo esenciales',
1448
+ customize: 'Personalizar',
1449
+ savePreferences: 'Guardar',
1450
+ essentialsOnly: 'Solo esenciales',
1451
+ preferencesTitle: 'Preferencias de cookies',
1452
+ cookieSettings: 'Configuración de cookies',
1453
+ cookies: 'Cookies',
1454
+ privacyPolicyLabel: 'Política de privacidad',
1455
+ };
1456
+ const it = {
1457
+ title: 'Utilizziamo i cookie',
1458
+ description: 'Questo sito utilizza i cookie per migliorare la tua esperienza. Puoi scegliere quali cookie accettare.',
1459
+ acceptAll: 'Accetta tutti',
1460
+ rejectAll: 'Solo essenziali',
1461
+ customize: 'Personalizza',
1462
+ savePreferences: 'Salva',
1463
+ essentialsOnly: 'Solo essenziali',
1464
+ preferencesTitle: 'Preferenze cookie',
1465
+ cookieSettings: 'Impostazioni cookie',
1466
+ cookies: 'Cookie',
1467
+ privacyPolicyLabel: 'Informativa sulla privacy',
1468
+ };
1469
+ const nl = {
1470
+ title: 'Wij gebruiken cookies',
1471
+ description: 'Deze site maakt gebruik van cookies om uw ervaring te verbeteren. U kunt kiezen welke cookies u accepteert.',
1472
+ acceptAll: 'Alles accepteren',
1473
+ rejectAll: 'Alleen essentieel',
1474
+ customize: 'Aanpassen',
1475
+ savePreferences: 'Opslaan',
1476
+ essentialsOnly: 'Alleen essentieel',
1477
+ preferencesTitle: 'Cookie-voorkeuren',
1478
+ cookieSettings: 'Cookie-instellingen',
1479
+ cookies: 'Cookies',
1480
+ privacyPolicyLabel: 'Privacybeleid',
1481
+ };
1482
+ const pt = {
1483
+ title: 'Utilizamos cookies',
1484
+ description: 'Este site utiliza cookies para melhorar a sua experiência. Pode escolher quais cookies aceita.',
1485
+ acceptAll: 'Aceitar todos',
1486
+ rejectAll: 'Apenas essenciais',
1487
+ customize: 'Personalizar',
1488
+ savePreferences: 'Guardar',
1489
+ essentialsOnly: 'Apenas essenciais',
1490
+ preferencesTitle: 'Preferências de cookies',
1491
+ cookieSettings: 'Definições de cookies',
1492
+ cookies: 'Cookies',
1493
+ privacyPolicyLabel: 'Política de privacidade',
1494
+ };
1495
+ const builtInTranslations = {
1496
+ en,
1497
+ fr,
1498
+ de,
1499
+ es,
1500
+ it,
1501
+ nl,
1502
+ pt,
1503
+ };
1504
+
1321
1505
  /**
1322
1506
  * CookieConsent - Main orchestrator class
1323
1507
  */
@@ -1327,6 +1511,16 @@
1327
1511
  this.preferenceCenter = null;
1328
1512
  this.floatingWidget = null;
1329
1513
  this.gtmIntegration = null;
1514
+ this.hideTimeout = null;
1515
+ // SSR guard
1516
+ if (typeof window === 'undefined') {
1517
+ this.config = config;
1518
+ this.consentManager = null;
1519
+ this.storageManager = null;
1520
+ this.eventEmitter = null;
1521
+ this.scriptBlocker = null;
1522
+ return;
1523
+ }
1330
1524
  this.config = this.validateConfig(config);
1331
1525
  this.consentManager = new ConsentManager(this.config);
1332
1526
  this.storageManager = new StorageManager();
@@ -1335,25 +1529,32 @@
1335
1529
  if (this.config.gtmConsentMode) {
1336
1530
  this.gtmIntegration = new GTMConsentMode(new DataLayerManager(), this.config);
1337
1531
  }
1338
- // Listen for preference center requests
1339
- this.eventEmitter.on('preferences:show', () => {
1340
- this.showPreferences();
1341
- });
1342
- // Listen for consent updates
1532
+ // Listen for consent events — callbacks are fired AFTER consent is persisted
1343
1533
  this.eventEmitter.on('consent:accept', (categories) => {
1534
+ var _a, _b;
1344
1535
  this.updateConsent(categories);
1536
+ (_b = (_a = this.config).onAccept) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1345
1537
  });
1346
1538
  this.eventEmitter.on('consent:reject', (categories) => {
1539
+ var _a, _b;
1347
1540
  this.updateConsent(categories);
1541
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1348
1542
  });
1349
1543
  this.eventEmitter.on('consent:update', (categories) => {
1544
+ var _a, _b;
1350
1545
  this.updateConsent(categories);
1546
+ (_b = (_a = this.config).onChange) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1547
+ });
1548
+ this.eventEmitter.on('preferences:show', () => {
1549
+ this.showPreferences();
1351
1550
  });
1352
1551
  }
1353
1552
  /**
1354
1553
  * Initialize the cookie consent system
1355
1554
  */
1356
1555
  init() {
1556
+ if (typeof window === 'undefined')
1557
+ return;
1357
1558
  // 1. Start blocking scripts immediately
1358
1559
  this.scriptBlocker.init();
1359
1560
  // 2. Set GTM default consent BEFORE checking storage
@@ -1363,35 +1564,28 @@
1363
1564
  // 3. Check for existing consent
1364
1565
  const storedConsent = this.storageManager.load();
1365
1566
  if (storedConsent && !this.storageManager.isExpired(storedConsent)) {
1366
- // Valid consent exists
1367
1567
  if (this.consentManager.needsUpdate(storedConsent)) {
1368
- // Policy updated, show banner again
1369
1568
  if (this.config.autoShow) {
1370
1569
  this.showBanner();
1371
1570
  }
1372
1571
  }
1373
1572
  else {
1374
- // Apply stored consent
1375
1573
  this.applyConsent(storedConsent.categories);
1376
- // Restore GTM consent for returning visitors (within wait_for_update window)
1377
1574
  if (this.gtmIntegration) {
1378
1575
  this.gtmIntegration.updateConsent(storedConsent.categories);
1379
1576
  }
1380
1577
  this.eventEmitter.emit('consent:load', storedConsent);
1381
- // Show floating widget if enabled
1382
1578
  if (this.config.showWidget) {
1383
1579
  this.showFloatingWidget();
1384
1580
  }
1385
1581
  }
1386
1582
  }
1387
1583
  else {
1388
- // No consent or expired
1389
1584
  if (this.config.autoShow) {
1390
1585
  this.showBanner();
1391
1586
  }
1392
1587
  }
1393
- // Store instance globally so it won't be garbage collected
1394
- // when used without a variable (e.g. new CookieConsent({}).init())
1588
+ // Store instance globally
1395
1589
  window.cookieConsent = this;
1396
1590
  this.eventEmitter.emit('consent:init');
1397
1591
  }
@@ -1414,14 +1608,18 @@
1414
1608
  showPreferences() {
1415
1609
  var _a;
1416
1610
  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)
1611
+ // Default to all ON when no prior consent
1418
1612
  const currentConsent = stored || {
1419
1613
  necessary: true,
1420
1614
  analytics: true,
1421
1615
  marketing: true,
1422
- preferences: true,
1423
1616
  };
1424
- // Always recreate to get fresh state from storage
1617
+ // Add any configured categories not in current consent
1618
+ for (const key of Object.keys(this.config.categories)) {
1619
+ if (!(key in currentConsent)) {
1620
+ currentConsent[key] = key === 'necessary';
1621
+ }
1622
+ }
1425
1623
  if (this.preferenceCenter) {
1426
1624
  this.preferenceCenter.destroy();
1427
1625
  }
@@ -1438,11 +1636,11 @@
1438
1636
  if (this.gtmIntegration) {
1439
1637
  this.gtmIntegration.updateConsent(categories);
1440
1638
  }
1441
- // Show floating widget after consent is given (delay to let banner hide)
1639
+ // Show floating widget after consent is given
1442
1640
  if (this.config.showWidget) {
1443
1641
  setTimeout(() => {
1444
1642
  this.showFloatingWidget();
1445
- }, 400); // Wait for banner hide animation
1643
+ }, 400);
1446
1644
  }
1447
1645
  }
1448
1646
  /**
@@ -1455,14 +1653,19 @@
1455
1653
  * Reset consent (clear stored data and show banner)
1456
1654
  */
1457
1655
  reset() {
1656
+ var _a, _b;
1458
1657
  this.storageManager.clear();
1459
1658
  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
1659
+ const denied = { necessary: true, analytics: false, marketing: false };
1660
+ for (const key of Object.keys(this.config.categories)) {
1661
+ if (key !== 'necessary')
1662
+ denied[key] = false;
1663
+ }
1664
+ clearDeniedCookies(denied);
1463
1665
  if (this.gtmIntegration) {
1464
- this.gtmIntegration.updateConsent({ necessary: true, analytics: false, marketing: false });
1666
+ this.gtmIntegration.updateConsent(denied);
1465
1667
  }
1668
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1466
1669
  this.showBanner();
1467
1670
  }
1468
1671
  /**
@@ -1482,6 +1685,10 @@
1482
1685
  */
1483
1686
  destroy() {
1484
1687
  var _a, _b, _c, _d;
1688
+ if (this.hideTimeout) {
1689
+ clearTimeout(this.hideTimeout);
1690
+ this.hideTimeout = null;
1691
+ }
1485
1692
  (_a = this.banner) === null || _a === void 0 ? void 0 : _a.destroy();
1486
1693
  this.banner = null;
1487
1694
  (_b = this.preferenceCenter) === null || _b === void 0 ? void 0 : _b.destroy();
@@ -1489,6 +1696,10 @@
1489
1696
  (_c = this.floatingWidget) === null || _c === void 0 ? void 0 : _c.destroy();
1490
1697
  this.floatingWidget = null;
1491
1698
  (_d = this.scriptBlocker) === null || _d === void 0 ? void 0 : _d.destroy();
1699
+ this.eventEmitter.clear();
1700
+ if (window.cookieConsent === this) {
1701
+ window.cookieConsent = undefined;
1702
+ }
1492
1703
  }
1493
1704
  /**
1494
1705
  * Show the banner
@@ -1514,33 +1725,37 @@
1514
1725
  */
1515
1726
  applyConsent(categories) {
1516
1727
  this.scriptBlocker.unblock(categories);
1517
- // CNIL/GDPR: actively delete cookies for denied categories
1518
1728
  clearDeniedCookies(categories);
1519
1729
  }
1520
1730
  /**
1521
1731
  * Validate and set default config values
1522
1732
  */
1523
1733
  validateConfig(config) {
1524
- return Object.assign(Object.assign({}, config), { categories: config.categories || {
1734
+ var _a;
1735
+ // Merge built-in language translations with user overrides
1736
+ const langKey = ((_a = config.language) === null || _a === void 0 ? void 0 : _a.toLowerCase().split('-')[0]) || 'en';
1737
+ const langDefaults = builtInTranslations[langKey] || builtInTranslations['en'];
1738
+ const mergedTranslations = Object.assign(Object.assign({}, langDefaults), config.translations);
1739
+ return Object.assign(Object.assign({}, config), { translations: mergedTranslations, categories: config.categories || {
1525
1740
  necessary: {
1526
1741
  enabled: true,
1527
1742
  readOnly: true,
1528
- label: 'Nécessaires',
1529
- description: 'Ces cookies sont indispensables au fonctionnement du site.',
1743
+ label: 'Essential',
1744
+ description: 'Required for the website to function properly.',
1530
1745
  },
1531
1746
  analytics: {
1532
1747
  enabled: true,
1533
1748
  readOnly: false,
1534
- label: 'Analytiques',
1535
- description: 'Ces cookies nous aident à comprendre comment vous utilisez le site.',
1749
+ label: 'Analytics',
1750
+ description: 'Help us understand how you use our site.',
1536
1751
  },
1537
1752
  marketing: {
1538
1753
  enabled: true,
1539
1754
  readOnly: false,
1540
1755
  label: 'Marketing',
1541
- description: 'Ces cookies sont utilisés pour vous proposer des publicités pertinentes.',
1756
+ description: 'Used to deliver relevant advertisements.',
1542
1757
  },
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' });
1758
+ }, 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
1759
  }
1545
1760
  }
1546
1761