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.
@@ -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
  })()
@@ -844,21 +931,12 @@
844
931
  data-theme="${escapeHtml(theme)}"
845
932
  style="${colorStyle}"
846
933
  >
847
- <div class="cc-modal__overlay" data-action="close"></div>
934
+ <div class="cc-modal__overlay"></div>
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
- <button
854
- class="cc-modal__close"
855
- aria-label="Fermer"
856
- data-action="close"
857
- >
858
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
859
- <path d="M18 6L6 18M6 6l12 12" stroke-width="2" stroke-linecap="round"/>
860
- </svg>
861
- </button>
862
940
  </div>
863
941
 
864
942
  <div class="cc-modal__body">
@@ -874,13 +952,13 @@
874
952
  class="cc-btn cc-btn--secondary"
875
953
  data-action="reject"
876
954
  >
877
- ${escapeHtml(translations.essentialsOnly || 'Uniquement les essentiels')}
955
+ ${escapeHtml(translations.essentialsOnly || 'Essentials only')}
878
956
  </button>
879
957
  <button
880
958
  class="cc-btn cc-btn--primary"
881
959
  data-action="save"
882
960
  >
883
- ${escapeHtml(translations.savePreferences || 'Enregistrer mes choix')}
961
+ ${escapeHtml(translations.savePreferences || 'Save preferences')}
884
962
  </button>
885
963
  </div>
886
964
  </div>
@@ -898,7 +976,7 @@
898
976
  const categories = Object.entries(this.config.categories);
899
977
  return categories
900
978
  .map(([key, config]) => {
901
- const checked = this.currentConsent[key];
979
+ const checked = this.currentConsent[key] === true;
902
980
  const disabled = config.readOnly;
903
981
  return `
904
982
  <div class="cc-category">
@@ -927,40 +1005,34 @@
927
1005
  * Attach event listeners
928
1006
  */
929
1007
  attachListeners() {
930
- var _a, _b;
1008
+ var _a;
931
1009
  (_a = this.element) === null || _a === void 0 ? void 0 : _a.addEventListener('click', (e) => {
932
1010
  const target = e.target.closest('[data-action]');
933
1011
  if (!target)
934
1012
  return;
935
1013
  const action = target.getAttribute('data-action');
936
- if (action === 'close') {
937
- this.hide();
938
- }
939
- else if (action === 'save') {
1014
+ if (action === 'save') {
940
1015
  this.handleSave();
941
1016
  }
942
1017
  else if (action === 'reject') {
943
1018
  this.handleRejectAll();
944
1019
  }
945
1020
  });
946
- // Keyboard shortcuts
947
- (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
948
- if (e.key === 'Escape') {
949
- this.hide();
950
- }
951
- });
952
1021
  }
953
1022
  /**
954
1023
  * Handle save preferences
955
1024
  */
956
1025
  handleSave() {
957
- var _a, _b, _c;
1026
+ var _a;
958
1027
  const checkboxes = (_a = this.element) === null || _a === void 0 ? void 0 : _a.querySelectorAll('input[data-category]');
959
- const categories = {
960
- necessary: true,
961
- analytics: false,
962
- marketing: false,
963
- };
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
964
1036
  checkboxes === null || checkboxes === void 0 ? void 0 : checkboxes.forEach((checkbox) => {
965
1037
  if (checkbox instanceof HTMLInputElement) {
966
1038
  const category = checkbox.getAttribute('data-category');
@@ -970,22 +1042,16 @@
970
1042
  }
971
1043
  });
972
1044
  this.eventEmitter.emit('consent:update', categories);
973
- (_c = (_b = this.config).onChange) === null || _c === void 0 ? void 0 : _c.call(_b, categories);
974
1045
  this.hide();
975
1046
  }
976
1047
  /**
977
1048
  * Handle reject all
978
1049
  */
979
1050
  handleRejectAll() {
980
- var _a;
981
- const necessaryOnly = {
982
- necessary: true,
983
- analytics: false,
984
- marketing: false,
985
- };
986
- // Only add preferences if it's configured
987
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
988
- 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;
989
1055
  }
990
1056
  this.eventEmitter.emit('consent:reject', necessaryOnly);
991
1057
  this.hide();
@@ -1000,9 +1066,7 @@
1000
1066
  return;
1001
1067
  const firstFocusable = focusableElements[0];
1002
1068
  const lastFocusable = focusableElements[focusableElements.length - 1];
1003
- // Focus first element
1004
1069
  firstFocusable === null || firstFocusable === void 0 ? void 0 : firstFocusable.focus();
1005
- // Trap focus
1006
1070
  (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
1007
1071
  if (e.key === 'Tab') {
1008
1072
  if (e.shiftKey && document.activeElement === firstFocusable) {
@@ -1090,7 +1154,7 @@
1090
1154
  <div
1091
1155
  class="cc-widget cc-widget--${escapeHtml(widgetPosition)} cc-widget--${escapeHtml(widgetStyle)}"
1092
1156
  role="button"
1093
- aria-label="${escapeHtml(translations.cookieSettings || 'Paramètres des cookies')}"
1157
+ aria-label="${escapeHtml(translations.cookieSettings || 'Cookie settings')}"
1094
1158
  tabindex="0"
1095
1159
  data-theme="${escapeHtml(theme)}"
1096
1160
  style="${colorStyle}"
@@ -1141,11 +1205,6 @@
1141
1205
 
1142
1206
  /**
1143
1207
  * GTMConsentMode - Full integration with Google Consent Mode v2
1144
- *
1145
- * Implements all required signals:
1146
- * - ad_storage, ad_user_data, ad_personalization, analytics_storage (core GCM v2)
1147
- * - functionality_storage, personalization_storage, security_storage (non-core)
1148
- * - wait_for_update, url_passthrough, ads_data_redaction (advanced features)
1149
1208
  */
1150
1209
  class GTMConsentMode {
1151
1210
  constructor(dataLayerManager, config) {
@@ -1165,15 +1224,13 @@
1165
1224
  analytics_storage: 'denied',
1166
1225
  functionality_storage: 'denied',
1167
1226
  personalization_storage: 'denied',
1168
- security_storage: 'granted', // Always granted
1227
+ security_storage: 'granted',
1169
1228
  };
1170
- // Add wait_for_update to give CMP time to restore returning visitor consent
1171
1229
  const waitForUpdate = (_a = this.config.gtmWaitForUpdate) !== null && _a !== void 0 ? _a : 500;
1172
1230
  if (waitForUpdate > 0) {
1173
1231
  defaults['wait_for_update'] = waitForUpdate;
1174
1232
  }
1175
1233
  this.dataLayerManager.pushConsent('default', defaults);
1176
- // Set advanced features via gtag('set', ...)
1177
1234
  if (this.config.gtmUrlPassthrough) {
1178
1235
  this.dataLayerManager.pushSet('url_passthrough', true);
1179
1236
  }
@@ -1183,7 +1240,6 @@
1183
1240
  }
1184
1241
  /**
1185
1242
  * Update consent state based on user choices
1186
- * Called both on new consent and on page load for returning visitors
1187
1243
  */
1188
1244
  updateConsent(categories) {
1189
1245
  const gtmConsent = this.mapCategoriesToGTM(categories);
@@ -1193,14 +1249,19 @@
1193
1249
  * Map consent categories to GTM Consent Mode v2 format
1194
1250
  */
1195
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;
1196
1257
  return {
1197
1258
  ad_storage: categories.marketing ? 'granted' : 'denied',
1198
1259
  ad_user_data: categories.marketing ? 'granted' : 'denied',
1199
1260
  ad_personalization: categories.marketing ? 'granted' : 'denied',
1200
1261
  analytics_storage: categories.analytics ? 'granted' : 'denied',
1201
- functionality_storage: categories.preferences ? 'granted' : 'denied',
1202
- personalization_storage: categories.preferences ? 'granted' : 'denied',
1203
- security_storage: 'granted', // Always granted
1262
+ functionality_storage: preferencesGranted ? 'granted' : 'denied',
1263
+ personalization_storage: preferencesGranted ? 'granted' : 'denied',
1264
+ security_storage: 'granted',
1204
1265
  };
1205
1266
  }
1206
1267
  }
@@ -1345,6 +1406,16 @@
1345
1406
  this.preferenceCenter = null;
1346
1407
  this.floatingWidget = null;
1347
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
+ }
1348
1419
  this.config = this.validateConfig(config);
1349
1420
  this.consentManager = new ConsentManager(this.config);
1350
1421
  this.storageManager = new StorageManager();
@@ -1353,25 +1424,32 @@
1353
1424
  if (this.config.gtmConsentMode) {
1354
1425
  this.gtmIntegration = new GTMConsentMode(new DataLayerManager(), this.config);
1355
1426
  }
1356
- // Listen for preference center requests
1357
- this.eventEmitter.on('preferences:show', () => {
1358
- this.showPreferences();
1359
- });
1360
- // Listen for consent updates
1427
+ // Listen for consent events — callbacks are fired AFTER consent is persisted
1361
1428
  this.eventEmitter.on('consent:accept', (categories) => {
1429
+ var _a, _b;
1362
1430
  this.updateConsent(categories);
1431
+ (_b = (_a = this.config).onAccept) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1363
1432
  });
1364
1433
  this.eventEmitter.on('consent:reject', (categories) => {
1434
+ var _a, _b;
1365
1435
  this.updateConsent(categories);
1436
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1366
1437
  });
1367
1438
  this.eventEmitter.on('consent:update', (categories) => {
1439
+ var _a, _b;
1368
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();
1369
1445
  });
1370
1446
  }
1371
1447
  /**
1372
1448
  * Initialize the cookie consent system
1373
1449
  */
1374
1450
  init() {
1451
+ if (typeof window === 'undefined')
1452
+ return;
1375
1453
  // 1. Start blocking scripts immediately
1376
1454
  this.scriptBlocker.init();
1377
1455
  // 2. Set GTM default consent BEFORE checking storage
@@ -1381,35 +1459,28 @@
1381
1459
  // 3. Check for existing consent
1382
1460
  const storedConsent = this.storageManager.load();
1383
1461
  if (storedConsent && !this.storageManager.isExpired(storedConsent)) {
1384
- // Valid consent exists
1385
1462
  if (this.consentManager.needsUpdate(storedConsent)) {
1386
- // Policy updated, show banner again
1387
1463
  if (this.config.autoShow) {
1388
1464
  this.showBanner();
1389
1465
  }
1390
1466
  }
1391
1467
  else {
1392
- // Apply stored consent
1393
1468
  this.applyConsent(storedConsent.categories);
1394
- // Restore GTM consent for returning visitors (within wait_for_update window)
1395
1469
  if (this.gtmIntegration) {
1396
1470
  this.gtmIntegration.updateConsent(storedConsent.categories);
1397
1471
  }
1398
1472
  this.eventEmitter.emit('consent:load', storedConsent);
1399
- // Show floating widget if enabled
1400
1473
  if (this.config.showWidget) {
1401
1474
  this.showFloatingWidget();
1402
1475
  }
1403
1476
  }
1404
1477
  }
1405
1478
  else {
1406
- // No consent or expired
1407
1479
  if (this.config.autoShow) {
1408
1480
  this.showBanner();
1409
1481
  }
1410
1482
  }
1411
- // Store instance globally so it won't be garbage collected
1412
- // when used without a variable (e.g. new CookieConsent({}).init())
1483
+ // Store instance globally
1413
1484
  window.cookieConsent = this;
1414
1485
  this.eventEmitter.emit('consent:init');
1415
1486
  }
@@ -1432,14 +1503,18 @@
1432
1503
  showPreferences() {
1433
1504
  var _a;
1434
1505
  const stored = (_a = this.storageManager.load()) === null || _a === void 0 ? void 0 : _a.categories;
1435
- // Default to all ON when no prior consent (user chose to customize)
1506
+ // Default to all ON when no prior consent
1436
1507
  const currentConsent = stored || {
1437
1508
  necessary: true,
1438
1509
  analytics: true,
1439
1510
  marketing: true,
1440
- preferences: true,
1441
1511
  };
1442
- // 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
+ }
1443
1518
  if (this.preferenceCenter) {
1444
1519
  this.preferenceCenter.destroy();
1445
1520
  }
@@ -1456,11 +1531,11 @@
1456
1531
  if (this.gtmIntegration) {
1457
1532
  this.gtmIntegration.updateConsent(categories);
1458
1533
  }
1459
- // Show floating widget after consent is given (delay to let banner hide)
1534
+ // Show floating widget after consent is given
1460
1535
  if (this.config.showWidget) {
1461
1536
  setTimeout(() => {
1462
1537
  this.showFloatingWidget();
1463
- }, 400); // Wait for banner hide animation
1538
+ }, 400);
1464
1539
  }
1465
1540
  }
1466
1541
  /**
@@ -1473,14 +1548,19 @@
1473
1548
  * Reset consent (clear stored data and show banner)
1474
1549
  */
1475
1550
  reset() {
1551
+ var _a, _b;
1476
1552
  this.storageManager.clear();
1477
1553
  this.scriptBlocker.block();
1478
- // Clear all non-essential cookies on reset
1479
- clearDeniedCookies({ necessary: true, analytics: false, marketing: false, preferences: false });
1480
- // 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);
1481
1560
  if (this.gtmIntegration) {
1482
- this.gtmIntegration.updateConsent({ necessary: true, analytics: false, marketing: false });
1561
+ this.gtmIntegration.updateConsent(denied);
1483
1562
  }
1563
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1484
1564
  this.showBanner();
1485
1565
  }
1486
1566
  /**
@@ -1500,6 +1580,10 @@
1500
1580
  */
1501
1581
  destroy() {
1502
1582
  var _a, _b, _c, _d;
1583
+ if (this.hideTimeout) {
1584
+ clearTimeout(this.hideTimeout);
1585
+ this.hideTimeout = null;
1586
+ }
1503
1587
  (_a = this.banner) === null || _a === void 0 ? void 0 : _a.destroy();
1504
1588
  this.banner = null;
1505
1589
  (_b = this.preferenceCenter) === null || _b === void 0 ? void 0 : _b.destroy();
@@ -1507,6 +1591,10 @@
1507
1591
  (_c = this.floatingWidget) === null || _c === void 0 ? void 0 : _c.destroy();
1508
1592
  this.floatingWidget = null;
1509
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
+ }
1510
1598
  }
1511
1599
  /**
1512
1600
  * Show the banner
@@ -1532,7 +1620,6 @@
1532
1620
  */
1533
1621
  applyConsent(categories) {
1534
1622
  this.scriptBlocker.unblock(categories);
1535
- // CNIL/GDPR: actively delete cookies for denied categories
1536
1623
  clearDeniedCookies(categories);
1537
1624
  }
1538
1625
  /**
@@ -1543,22 +1630,22 @@
1543
1630
  necessary: {
1544
1631
  enabled: true,
1545
1632
  readOnly: true,
1546
- label: 'Nécessaires',
1547
- description: 'Ces cookies sont indispensables au fonctionnement du site.',
1633
+ label: 'Essential',
1634
+ description: 'Required for the website to function properly.',
1548
1635
  },
1549
1636
  analytics: {
1550
1637
  enabled: true,
1551
1638
  readOnly: false,
1552
- label: 'Analytiques',
1553
- description: 'Ces cookies nous aident à comprendre comment vous utilisez le site.',
1639
+ label: 'Analytics',
1640
+ description: 'Help us understand how you use our site.',
1554
1641
  },
1555
1642
  marketing: {
1556
1643
  enabled: true,
1557
1644
  readOnly: false,
1558
1645
  label: 'Marketing',
1559
- description: 'Ces cookies sont utilisés pour vous proposer des publicités pertinentes.',
1646
+ description: 'Used to deliver relevant advertisements.',
1560
1647
  },
1561
- }, 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' });
1562
1649
  }
1563
1650
  }
1564
1651