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.
@@ -43,13 +43,20 @@ class StorageManager {
43
43
  * Clear consent record from localStorage
44
44
  */
45
45
  clear() {
46
- localStorage.removeItem(StorageManager.STORAGE_KEY);
46
+ try {
47
+ localStorage.removeItem(StorageManager.STORAGE_KEY);
48
+ }
49
+ catch (e) {
50
+ console.error('Failed to clear consent:', e);
51
+ }
47
52
  }
48
53
  /**
49
54
  * Check if consent record has expired
50
55
  */
51
56
  isExpired(consent) {
52
57
  const expiry = new Date(consent.expiresAt);
58
+ if (isNaN(expiry.getTime()))
59
+ return true;
53
60
  return expiry < new Date();
54
61
  }
55
62
  /**
@@ -60,7 +67,6 @@ class StorageManager {
60
67
  typeof data.version === 'number' &&
61
68
  typeof data.timestamp === 'string' &&
62
69
  typeof data.categories === 'object' &&
63
- typeof data.userAgent === 'string' &&
64
70
  typeof data.expiresAt === 'string');
65
71
  }
66
72
  /**
@@ -76,11 +82,20 @@ class StorageManager {
76
82
  const now = new Date();
77
83
  const expiryDate = new Date(now);
78
84
  expiryDate.setMonth(expiryDate.getMonth() + StorageManager.EXPIRY_MONTHS);
85
+ // Coerce category values to booleans
86
+ const rawCategories = record.categories;
87
+ const categories = {
88
+ necessary: rawCategories.necessary === true,
89
+ analytics: rawCategories.analytics === true,
90
+ marketing: rawCategories.marketing === true,
91
+ };
92
+ if ('preferences' in rawCategories) {
93
+ categories.preferences = rawCategories.preferences === true;
94
+ }
79
95
  return {
80
96
  version: typeof record.version === 'number' ? record.version : 1,
81
97
  timestamp: typeof record.timestamp === 'string' ? record.timestamp : now.toISOString(),
82
- categories: record.categories,
83
- userAgent: typeof record.userAgent === 'string' ? record.userAgent : navigator.userAgent,
98
+ categories,
84
99
  expiresAt: typeof record.expiresAt === 'string' ? record.expiresAt : expiryDate.toISOString(),
85
100
  };
86
101
  }
@@ -95,6 +110,7 @@ StorageManager.EXPIRY_MONTHS = 13;
95
110
  */
96
111
  class ConsentManager {
97
112
  constructor(config) {
113
+ this.consent = null;
98
114
  this.config = config;
99
115
  }
100
116
  /**
@@ -113,6 +129,10 @@ class ConsentManager {
113
129
  }
114
130
  }
115
131
  }
132
+ // Coerce all values to booleans
133
+ for (const key of Object.keys(categories)) {
134
+ categories[key] = categories[key] === true;
135
+ }
116
136
  return true;
117
137
  }
118
138
  /**
@@ -129,13 +149,12 @@ class ConsentManager {
129
149
  * Check if user needs to give consent
130
150
  */
131
151
  needsConsent() {
132
- return this.consent === undefined;
152
+ return this.consent === null;
133
153
  }
134
154
  /**
135
155
  * Check if stored consent needs update due to policy change
136
156
  */
137
157
  needsUpdate(storedConsent) {
138
- // Check if policy version has changed
139
158
  return storedConsent.version < this.config.revision;
140
159
  }
141
160
  /**
@@ -155,7 +174,6 @@ class ConsentManager {
155
174
  version: this.config.revision,
156
175
  timestamp: now.toISOString(),
157
176
  categories: Object.assign({}, categories),
158
- userAgent: navigator.userAgent,
159
177
  expiresAt: expiryDate.toISOString(),
160
178
  };
161
179
  }
@@ -396,7 +414,6 @@ class ScriptBlocker {
396
414
  class CategoryManager {
397
415
  constructor() {
398
416
  this.categories = new Map();
399
- // Initialize with common patterns
400
417
  this.initializeDefaultPatterns();
401
418
  }
402
419
  /**
@@ -433,11 +450,11 @@ class CategoryManager {
433
450
  }
434
451
  /**
435
452
  * Initialize default URL patterns for common tracking services
453
+ * Note: GTM is NOT auto-categorized — it should be managed via GTM Consent Mode v2
436
454
  */
437
455
  initializeDefaultPatterns() {
438
456
  this.categories.set('analytics', [
439
457
  'google-analytics.com',
440
- 'googletagmanager.com',
441
458
  'analytics.google.com',
442
459
  'plausible.io',
443
460
  'matomo.org',
@@ -447,6 +464,7 @@ class CategoryManager {
447
464
  'amplitude.com',
448
465
  ]);
449
466
  this.categories.set('marketing', [
467
+ 'googletagmanager.com',
450
468
  'facebook.net',
451
469
  'facebook.com/tr',
452
470
  'connect.facebook.net',
@@ -458,6 +476,7 @@ class CategoryManager {
458
476
  'adroll.com',
459
477
  'taboola.com',
460
478
  'outbrain.com',
479
+ 'tiktok.com',
461
480
  ]);
462
481
  this.categories.set('necessary', []);
463
482
  }
@@ -508,18 +527,47 @@ function sanitizeColor(color) {
508
527
  // Allow hsl/hsla
509
528
  if (/^hsla?\(\s*[\d\s,./%deg]+\)$/.test(trimmed))
510
529
  return trimmed;
511
- // Allow CSS named colors (basic set)
512
- if (/^[a-zA-Z]+$/.test(trimmed))
530
+ // Allow CSS named colors (basic set) but block CSS keywords that could be abused
531
+ const CSS_KEYWORDS = ['inherit', 'initial', 'unset', 'revert', 'revert-layer'];
532
+ if (/^[a-zA-Z]+$/.test(trimmed) && !CSS_KEYWORDS.includes(trimmed.toLowerCase())) {
513
533
  return trimmed;
534
+ }
514
535
  return '';
515
536
  }
516
537
 
538
+ /**
539
+ * Normalize any supported color format to 6-digit hex
540
+ * Supports: #RGB, #RRGGBB, #RRGGBBAA, named colors
541
+ * Returns null if conversion fails
542
+ */
543
+ function normalizeToHex6(color) {
544
+ const trimmed = color.trim();
545
+ // Already 6-digit hex
546
+ if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
547
+ return trimmed;
548
+ }
549
+ // 3-digit hex → expand to 6-digit
550
+ if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
551
+ const r = trimmed[1];
552
+ const g = trimmed[2];
553
+ const b = trimmed[3];
554
+ return `#${r}${r}${g}${g}${b}${b}`;
555
+ }
556
+ // 8-digit hex (with alpha) → strip alpha
557
+ if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) {
558
+ return trimmed.substring(0, 7);
559
+ }
560
+ return null;
561
+ }
517
562
  /**
518
563
  * Adjust a hex color brightness by a percentage
519
564
  * Negative = darker, positive = lighter
520
565
  */
521
566
  function adjustColorBrightness(color, percent) {
522
- const hex = color.replace('#', '');
567
+ const hex6 = normalizeToHex6(color);
568
+ if (!hex6)
569
+ return color;
570
+ const hex = hex6.replace('#', '');
523
571
  const r = parseInt(hex.substring(0, 2), 16);
524
572
  const g = parseInt(hex.substring(2, 4), 16);
525
573
  const b = parseInt(hex.substring(4, 6), 16);
@@ -539,8 +587,13 @@ function adjustColorBrightness(color, percent) {
539
587
  function buildColorStyle(safeColor) {
540
588
  if (!safeColor)
541
589
  return '';
542
- const hover = adjustColorBrightness(safeColor, -15);
543
- return `--cc-primary: ${safeColor}; --cc-primary-hover: ${hover};`;
590
+ // Only generate hover color for hex colors
591
+ const hex6 = normalizeToHex6(safeColor);
592
+ if (!hex6) {
593
+ return `--cc-primary: ${safeColor};`;
594
+ }
595
+ const hover = adjustColorBrightness(hex6, -15);
596
+ return `--cc-primary: ${hex6}; --cc-primary-hover: ${hover};`;
544
597
  }
545
598
 
546
599
  /**
@@ -549,6 +602,8 @@ function buildColorStyle(safeColor) {
549
602
  class Banner {
550
603
  constructor(config, eventEmitter) {
551
604
  this.element = null;
605
+ this.hideTimeout = null;
606
+ this.previousActiveElement = null;
552
607
  this.config = config;
553
608
  this.eventEmitter = eventEmitter;
554
609
  }
@@ -556,8 +611,14 @@ class Banner {
556
611
  * Show the banner
557
612
  */
558
613
  show() {
614
+ // Clear any pending hide timeout
615
+ if (this.hideTimeout) {
616
+ clearTimeout(this.hideTimeout);
617
+ this.hideTimeout = null;
618
+ }
559
619
  const append = () => {
560
620
  if (!this.element) {
621
+ this.previousActiveElement = document.activeElement;
561
622
  this.element = this.createDOM();
562
623
  document.body.appendChild(this.element);
563
624
  this.attachListeners();
@@ -570,9 +631,9 @@ class Banner {
570
631
  // Disable page interaction if configured
571
632
  if (this.config.disablePageInteraction) {
572
633
  document.body.style.overflow = 'hidden';
634
+ this.trapFocus();
573
635
  }
574
636
  };
575
- // Wait for body if not yet available
576
637
  if (!document.body) {
577
638
  document.addEventListener('DOMContentLoaded', append);
578
639
  return;
@@ -585,22 +646,30 @@ class Banner {
585
646
  hide() {
586
647
  var _a;
587
648
  (_a = this.element) === null || _a === void 0 ? void 0 : _a.classList.remove('is-visible');
588
- // Re-enable page interaction
589
649
  if (this.config.disablePageInteraction) {
590
650
  document.body.style.overflow = '';
591
651
  }
592
- setTimeout(() => {
652
+ this.hideTimeout = setTimeout(() => {
593
653
  this.destroy();
594
- }, 300); // Match CSS transition
654
+ }, 300);
595
655
  }
596
656
  /**
597
657
  * Destroy the banner
598
658
  */
599
659
  destroy() {
660
+ if (this.hideTimeout) {
661
+ clearTimeout(this.hideTimeout);
662
+ this.hideTimeout = null;
663
+ }
600
664
  if (this.element) {
601
665
  this.element.remove();
602
666
  this.element = null;
603
667
  }
668
+ // Restore focus
669
+ if (this.previousActiveElement && document.contains(this.previousActiveElement)) {
670
+ this.previousActiveElement.focus();
671
+ this.previousActiveElement = null;
672
+ }
604
673
  }
605
674
  /**
606
675
  * Create DOM structure for banner
@@ -611,12 +680,14 @@ class Banner {
611
680
  const position = this.config.position || 'bottom';
612
681
  const layout = this.config.layout || 'bar';
613
682
  const backdropBlur = this.config.backdropBlur !== false;
683
+ const isModal = this.config.disablePageInteraction;
614
684
  const safeColor = this.config.primaryColor ? sanitizeColor(this.config.primaryColor) : '';
615
685
  const colorStyle = buildColorStyle(safeColor);
616
686
  const template = `
617
687
  <div
618
688
  class="cc-banner cc-banner--${escapeHtml(position)} cc-banner--${escapeHtml(layout)} ${backdropBlur ? 'cc-backdrop-blur' : ''}"
619
- role="region"
689
+ role="${isModal ? 'dialog' : 'region'}"
690
+ ${isModal ? 'aria-modal="true"' : ''}
620
691
  aria-label="Cookie consent"
621
692
  aria-live="polite"
622
693
  data-theme="${escapeHtml(theme)}"
@@ -625,7 +696,7 @@ class Banner {
625
696
  <div class="cc-banner__container">
626
697
  <div class="cc-banner__content">
627
698
  <h2 class="cc-banner__title">
628
- ${escapeHtml(translations.title || '🍪 Nous utilisons des cookies')}
699
+ ${escapeHtml(translations.title || 'We use cookies')}
629
700
  </h2>
630
701
  <p class="cc-banner__description">
631
702
  ${this.getDescriptionHTML()}
@@ -635,23 +706,23 @@ class Banner {
635
706
  <button
636
707
  class="cc-btn cc-btn--ghost"
637
708
  data-action="reject"
638
- aria-label="${escapeHtml(translations.rejectAll || 'Uniquement essentiels')}"
709
+ aria-label="${escapeHtml(translations.rejectAll || 'Essentials only')}"
639
710
  >
640
- ${escapeHtml(translations.rejectAll || 'Uniquement essentiels')}
711
+ ${escapeHtml(translations.rejectAll || 'Essentials only')}
641
712
  </button>
642
713
  <button
643
714
  class="cc-btn cc-btn--tertiary"
644
715
  data-action="customize"
645
- aria-label="${escapeHtml(translations.customize || 'Personnaliser')}"
716
+ aria-label="${escapeHtml(translations.customize || 'Customize')}"
646
717
  >
647
- ${escapeHtml(translations.customize || 'Personnaliser')}
718
+ ${escapeHtml(translations.customize || 'Customize')}
648
719
  </button>
649
720
  <button
650
721
  class="cc-btn cc-btn--accept"
651
722
  data-action="accept"
652
- aria-label="${escapeHtml(translations.acceptAll || 'Tout accepter')}"
723
+ aria-label="${escapeHtml(translations.acceptAll || 'Accept all')}"
653
724
  >
654
- ${escapeHtml(translations.acceptAll || 'Tout accepter')}
725
+ ${escapeHtml(translations.acceptAll || 'Accept all')}
655
726
  </button>
656
727
  </div>
657
728
  </div>
@@ -683,10 +754,8 @@ class Banner {
683
754
  break;
684
755
  }
685
756
  });
686
- // Keyboard support
687
757
  (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
688
758
  if (e.key === 'Escape' && this.config.disablePageInteraction) {
689
- // Allow ESC to close if page interaction is disabled
690
759
  this.handleRejectAll();
691
760
  }
692
761
  });
@@ -695,36 +764,24 @@ class Banner {
695
764
  * Handle accept all action
696
765
  */
697
766
  handleAcceptAll() {
698
- var _a, _b, _c;
699
- const allCategories = {
700
- necessary: true,
701
- analytics: true,
702
- marketing: true,
703
- };
704
- // Only add preferences if it's configured
705
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
706
- allCategories.preferences = true;
767
+ const allCategories = { necessary: true, analytics: true, marketing: true };
768
+ // Add all configured categories
769
+ for (const key of Object.keys(this.config.categories)) {
770
+ allCategories[key] = true;
707
771
  }
708
772
  this.eventEmitter.emit('consent:accept', allCategories);
709
- (_c = (_b = this.config).onAccept) === null || _c === void 0 ? void 0 : _c.call(_b, allCategories);
710
773
  this.hide();
711
774
  }
712
775
  /**
713
776
  * Handle reject all action
714
777
  */
715
778
  handleRejectAll() {
716
- var _a, _b, _c;
717
- const necessaryOnly = {
718
- necessary: true,
719
- analytics: false,
720
- marketing: false,
721
- };
722
- // Only add preferences if it's configured
723
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
724
- necessaryOnly.preferences = false;
779
+ const necessaryOnly = { necessary: true, analytics: false, marketing: false };
780
+ for (const key of Object.keys(this.config.categories)) {
781
+ if (key !== 'necessary')
782
+ necessaryOnly[key] = false;
725
783
  }
726
784
  this.eventEmitter.emit('consent:reject', necessaryOnly);
727
- (_c = (_b = this.config).onReject) === null || _c === void 0 ? void 0 : _c.call(_b);
728
785
  this.hide();
729
786
  }
730
787
  /**
@@ -734,17 +791,41 @@ class Banner {
734
791
  this.eventEmitter.emit('preferences:show');
735
792
  this.hide();
736
793
  }
794
+ /**
795
+ * Trap focus within banner (when disablePageInteraction is true)
796
+ */
797
+ trapFocus() {
798
+ var _a, _b;
799
+ const focusableElements = (_a = this.element) === null || _a === void 0 ? void 0 : _a.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
800
+ if (!focusableElements || focusableElements.length === 0)
801
+ return;
802
+ const firstFocusable = focusableElements[0];
803
+ const lastFocusable = focusableElements[focusableElements.length - 1];
804
+ firstFocusable === null || firstFocusable === void 0 ? void 0 : firstFocusable.focus();
805
+ (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
806
+ if (e.key === 'Tab') {
807
+ if (e.shiftKey && document.activeElement === firstFocusable) {
808
+ e.preventDefault();
809
+ lastFocusable.focus();
810
+ }
811
+ else if (!e.shiftKey && document.activeElement === lastFocusable) {
812
+ e.preventDefault();
813
+ firstFocusable.focus();
814
+ }
815
+ }
816
+ });
817
+ }
737
818
  /**
738
819
  * Generate description HTML with privacy policy link
739
820
  */
740
821
  getDescriptionHTML() {
741
822
  const translations = this.config.translations || {};
742
- const defaultDescription = 'Pour améliorer votre expérience sur notre site, nous utilisons des cookies. Vous pouvez choisir les cookies que vous acceptez.';
823
+ const defaultDescription = 'We use cookies to improve your experience on our site. You can choose which cookies you accept.';
743
824
  const description = escapeHtml(translations.description || defaultDescription);
744
825
  if (translations.privacyPolicyUrl) {
745
826
  const safeUrl = sanitizeUrl(translations.privacyPolicyUrl);
746
827
  if (safeUrl) {
747
- const linkLabel = escapeHtml(translations.privacyPolicyLabel || 'Politique de confidentialité');
828
+ const linkLabel = escapeHtml(translations.privacyPolicyLabel || 'Privacy Policy');
748
829
  return `${description} <a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkLabel}</a>`;
749
830
  }
750
831
  }
@@ -758,6 +839,7 @@ class Banner {
758
839
  class PreferenceCenter {
759
840
  constructor(config, eventEmitter, currentConsent) {
760
841
  this.element = null;
842
+ this.previousActiveElement = null;
761
843
  this.config = config;
762
844
  this.eventEmitter = eventEmitter;
763
845
  this.currentConsent = currentConsent;
@@ -768,13 +850,13 @@ class PreferenceCenter {
768
850
  show() {
769
851
  const append = () => {
770
852
  if (!this.element) {
853
+ this.previousActiveElement = document.activeElement;
771
854
  this.element = this.createDOM();
772
855
  document.body.appendChild(this.element);
773
856
  this.attachListeners();
774
857
  }
775
858
  this.element.classList.add('is-visible');
776
859
  this.trapFocus();
777
- // Prevent body scroll
778
860
  document.body.style.overflow = 'hidden';
779
861
  };
780
862
  if (!document.body) {
@@ -790,6 +872,11 @@ class PreferenceCenter {
790
872
  var _a;
791
873
  (_a = this.element) === null || _a === void 0 ? void 0 : _a.classList.remove('is-visible');
792
874
  document.body.style.overflow = '';
875
+ // Restore focus to triggering element
876
+ if (this.previousActiveElement && document.contains(this.previousActiveElement)) {
877
+ this.previousActiveElement.focus();
878
+ this.previousActiveElement = null;
879
+ }
793
880
  setTimeout(() => {
794
881
  this.destroy();
795
882
  }, 300);
@@ -824,7 +911,7 @@ class PreferenceCenter {
824
911
  <polyline points="15 3 21 3 21 9"/>
825
912
  <line x1="10" y1="14" x2="21" y2="3"/>
826
913
  </svg>
827
- ${escapeHtml(translations.privacyPolicyLabel || 'Politique de confidentialité')}
914
+ ${escapeHtml(translations.privacyPolicyLabel || 'Privacy Policy')}
828
915
  </a>
829
916
  `;
830
917
  })()
@@ -842,7 +929,7 @@ class PreferenceCenter {
842
929
  <div class="cc-modal__content">
843
930
  <div class="cc-modal__header">
844
931
  <h2 id="cc-modal-title">
845
- ${escapeHtml(translations.preferencesTitle || translations.title || 'Préférences de cookies')}
932
+ ${escapeHtml(translations.preferencesTitle || translations.title || 'Cookie Preferences')}
846
933
  </h2>
847
934
  </div>
848
935
 
@@ -859,13 +946,13 @@ class PreferenceCenter {
859
946
  class="cc-btn cc-btn--secondary"
860
947
  data-action="reject"
861
948
  >
862
- ${escapeHtml(translations.essentialsOnly || 'Uniquement les essentiels')}
949
+ ${escapeHtml(translations.essentialsOnly || 'Essentials only')}
863
950
  </button>
864
951
  <button
865
952
  class="cc-btn cc-btn--primary"
866
953
  data-action="save"
867
954
  >
868
- ${escapeHtml(translations.savePreferences || 'Enregistrer mes choix')}
955
+ ${escapeHtml(translations.savePreferences || 'Save preferences')}
869
956
  </button>
870
957
  </div>
871
958
  </div>
@@ -883,7 +970,7 @@ class PreferenceCenter {
883
970
  const categories = Object.entries(this.config.categories);
884
971
  return categories
885
972
  .map(([key, config]) => {
886
- const checked = this.currentConsent[key];
973
+ const checked = this.currentConsent[key] === true;
887
974
  const disabled = config.readOnly;
888
975
  return `
889
976
  <div class="cc-category">
@@ -930,13 +1017,16 @@ class PreferenceCenter {
930
1017
  * Handle save preferences
931
1018
  */
932
1019
  handleSave() {
933
- var _a, _b, _c;
1020
+ var _a;
934
1021
  const checkboxes = (_a = this.element) === null || _a === void 0 ? void 0 : _a.querySelectorAll('input[data-category]');
935
- const categories = {
936
- necessary: true,
937
- analytics: false,
938
- marketing: false,
939
- };
1022
+ // Initialize all configured categories to false
1023
+ const categories = { necessary: true, analytics: false, marketing: false };
1024
+ for (const key of Object.keys(this.config.categories)) {
1025
+ if (key !== 'necessary') {
1026
+ categories[key] = false;
1027
+ }
1028
+ }
1029
+ // Override with actual checkbox values
940
1030
  checkboxes === null || checkboxes === void 0 ? void 0 : checkboxes.forEach((checkbox) => {
941
1031
  if (checkbox instanceof HTMLInputElement) {
942
1032
  const category = checkbox.getAttribute('data-category');
@@ -946,22 +1036,16 @@ class PreferenceCenter {
946
1036
  }
947
1037
  });
948
1038
  this.eventEmitter.emit('consent:update', categories);
949
- (_c = (_b = this.config).onChange) === null || _c === void 0 ? void 0 : _c.call(_b, categories);
950
1039
  this.hide();
951
1040
  }
952
1041
  /**
953
1042
  * Handle reject all
954
1043
  */
955
1044
  handleRejectAll() {
956
- var _a;
957
- const necessaryOnly = {
958
- necessary: true,
959
- analytics: false,
960
- marketing: false,
961
- };
962
- // Only add preferences if it's configured
963
- if ((_a = this.config.categories) === null || _a === void 0 ? void 0 : _a.preferences) {
964
- necessaryOnly.preferences = false;
1045
+ const necessaryOnly = { necessary: true, analytics: false, marketing: false };
1046
+ for (const key of Object.keys(this.config.categories)) {
1047
+ if (key !== 'necessary')
1048
+ necessaryOnly[key] = false;
965
1049
  }
966
1050
  this.eventEmitter.emit('consent:reject', necessaryOnly);
967
1051
  this.hide();
@@ -976,9 +1060,7 @@ class PreferenceCenter {
976
1060
  return;
977
1061
  const firstFocusable = focusableElements[0];
978
1062
  const lastFocusable = focusableElements[focusableElements.length - 1];
979
- // Focus first element
980
1063
  firstFocusable === null || firstFocusable === void 0 ? void 0 : firstFocusable.focus();
981
- // Trap focus
982
1064
  (_b = this.element) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', (e) => {
983
1065
  if (e.key === 'Tab') {
984
1066
  if (e.shiftKey && document.activeElement === firstFocusable) {
@@ -1066,7 +1148,7 @@ class FloatingWidget {
1066
1148
  <div
1067
1149
  class="cc-widget cc-widget--${escapeHtml(widgetPosition)} cc-widget--${escapeHtml(widgetStyle)}"
1068
1150
  role="button"
1069
- aria-label="${escapeHtml(translations.cookieSettings || 'Paramètres des cookies')}"
1151
+ aria-label="${escapeHtml(translations.cookieSettings || 'Cookie settings')}"
1070
1152
  tabindex="0"
1071
1153
  data-theme="${escapeHtml(theme)}"
1072
1154
  style="${colorStyle}"
@@ -1117,11 +1199,6 @@ class FloatingWidget {
1117
1199
 
1118
1200
  /**
1119
1201
  * GTMConsentMode - Full integration with Google Consent Mode v2
1120
- *
1121
- * Implements all required signals:
1122
- * - ad_storage, ad_user_data, ad_personalization, analytics_storage (core GCM v2)
1123
- * - functionality_storage, personalization_storage, security_storage (non-core)
1124
- * - wait_for_update, url_passthrough, ads_data_redaction (advanced features)
1125
1202
  */
1126
1203
  class GTMConsentMode {
1127
1204
  constructor(dataLayerManager, config) {
@@ -1141,15 +1218,13 @@ class GTMConsentMode {
1141
1218
  analytics_storage: 'denied',
1142
1219
  functionality_storage: 'denied',
1143
1220
  personalization_storage: 'denied',
1144
- security_storage: 'granted', // Always granted
1221
+ security_storage: 'granted',
1145
1222
  };
1146
- // Add wait_for_update to give CMP time to restore returning visitor consent
1147
1223
  const waitForUpdate = (_a = this.config.gtmWaitForUpdate) !== null && _a !== void 0 ? _a : 500;
1148
1224
  if (waitForUpdate > 0) {
1149
1225
  defaults['wait_for_update'] = waitForUpdate;
1150
1226
  }
1151
1227
  this.dataLayerManager.pushConsent('default', defaults);
1152
- // Set advanced features via gtag('set', ...)
1153
1228
  if (this.config.gtmUrlPassthrough) {
1154
1229
  this.dataLayerManager.pushSet('url_passthrough', true);
1155
1230
  }
@@ -1159,7 +1234,6 @@ class GTMConsentMode {
1159
1234
  }
1160
1235
  /**
1161
1236
  * Update consent state based on user choices
1162
- * Called both on new consent and on page load for returning visitors
1163
1237
  */
1164
1238
  updateConsent(categories) {
1165
1239
  const gtmConsent = this.mapCategoriesToGTM(categories);
@@ -1169,14 +1243,19 @@ class GTMConsentMode {
1169
1243
  * Map consent categories to GTM Consent Mode v2 format
1170
1244
  */
1171
1245
  mapCategoriesToGTM(categories) {
1246
+ // When preferences category is not configured, default functionality to granted
1247
+ const hasPreferencesCategory = 'preferences' in this.config.categories;
1248
+ const preferencesGranted = hasPreferencesCategory
1249
+ ? categories.preferences === true
1250
+ : true;
1172
1251
  return {
1173
1252
  ad_storage: categories.marketing ? 'granted' : 'denied',
1174
1253
  ad_user_data: categories.marketing ? 'granted' : 'denied',
1175
1254
  ad_personalization: categories.marketing ? 'granted' : 'denied',
1176
1255
  analytics_storage: categories.analytics ? 'granted' : 'denied',
1177
- functionality_storage: categories.preferences ? 'granted' : 'denied',
1178
- personalization_storage: categories.preferences ? 'granted' : 'denied',
1179
- security_storage: 'granted', // Always granted
1256
+ functionality_storage: preferencesGranted ? 'granted' : 'denied',
1257
+ personalization_storage: preferencesGranted ? 'granted' : 'denied',
1258
+ security_storage: 'granted',
1180
1259
  };
1181
1260
  }
1182
1261
  }
@@ -1312,6 +1391,111 @@ function clearDeniedCookies(categories) {
1312
1391
  }
1313
1392
  }
1314
1393
 
1394
+ /**
1395
+ * Built-in translations for supported languages
1396
+ * Users can override any string via config.translations
1397
+ */
1398
+ const en = {
1399
+ title: 'We use cookies',
1400
+ description: 'We use cookies to improve your experience on our site. You can choose which cookies you accept.',
1401
+ acceptAll: 'Accept all',
1402
+ rejectAll: 'Essentials only',
1403
+ customize: 'Customize',
1404
+ savePreferences: 'Save preferences',
1405
+ essentialsOnly: 'Essentials only',
1406
+ preferencesTitle: 'Cookie Preferences',
1407
+ cookieSettings: 'Cookie settings',
1408
+ cookies: 'Cookies',
1409
+ privacyPolicyLabel: 'Privacy Policy',
1410
+ };
1411
+ const fr = {
1412
+ title: 'Nous utilisons des cookies',
1413
+ description: 'Ce site utilise des cookies pour améliorer votre expérience de navigation. Vous pouvez choisir les cookies que vous acceptez.',
1414
+ acceptAll: 'Tout accepter',
1415
+ rejectAll: 'Essentiels uniquement',
1416
+ customize: 'Personnaliser',
1417
+ savePreferences: 'Enregistrer',
1418
+ essentialsOnly: 'Essentiels uniquement',
1419
+ preferencesTitle: 'Préférences des cookies',
1420
+ cookieSettings: 'Paramètres des cookies',
1421
+ cookies: 'Cookies',
1422
+ privacyPolicyLabel: 'Politique de confidentialité',
1423
+ };
1424
+ const de = {
1425
+ title: 'Wir verwenden Cookies',
1426
+ description: 'Diese Website verwendet Cookies, um Ihr Erlebnis zu verbessern. Sie können wählen, welche Cookies Sie akzeptieren.',
1427
+ acceptAll: 'Alle akzeptieren',
1428
+ rejectAll: 'Nur essenzielle',
1429
+ customize: 'Anpassen',
1430
+ savePreferences: 'Speichern',
1431
+ essentialsOnly: 'Nur essenzielle',
1432
+ preferencesTitle: 'Cookie-Einstellungen',
1433
+ cookieSettings: 'Cookie-Einstellungen',
1434
+ cookies: 'Cookies',
1435
+ privacyPolicyLabel: 'Datenschutzrichtlinie',
1436
+ };
1437
+ const es = {
1438
+ title: 'Usamos cookies',
1439
+ description: 'Este sitio utiliza cookies para mejorar su experiencia. Puede elegir qué cookies acepta.',
1440
+ acceptAll: 'Aceptar todo',
1441
+ rejectAll: 'Solo esenciales',
1442
+ customize: 'Personalizar',
1443
+ savePreferences: 'Guardar',
1444
+ essentialsOnly: 'Solo esenciales',
1445
+ preferencesTitle: 'Preferencias de cookies',
1446
+ cookieSettings: 'Configuración de cookies',
1447
+ cookies: 'Cookies',
1448
+ privacyPolicyLabel: 'Política de privacidad',
1449
+ };
1450
+ const it = {
1451
+ title: 'Utilizziamo i cookie',
1452
+ description: 'Questo sito utilizza i cookie per migliorare la tua esperienza. Puoi scegliere quali cookie accettare.',
1453
+ acceptAll: 'Accetta tutti',
1454
+ rejectAll: 'Solo essenziali',
1455
+ customize: 'Personalizza',
1456
+ savePreferences: 'Salva',
1457
+ essentialsOnly: 'Solo essenziali',
1458
+ preferencesTitle: 'Preferenze cookie',
1459
+ cookieSettings: 'Impostazioni cookie',
1460
+ cookies: 'Cookie',
1461
+ privacyPolicyLabel: 'Informativa sulla privacy',
1462
+ };
1463
+ const nl = {
1464
+ title: 'Wij gebruiken cookies',
1465
+ description: 'Deze site maakt gebruik van cookies om uw ervaring te verbeteren. U kunt kiezen welke cookies u accepteert.',
1466
+ acceptAll: 'Alles accepteren',
1467
+ rejectAll: 'Alleen essentieel',
1468
+ customize: 'Aanpassen',
1469
+ savePreferences: 'Opslaan',
1470
+ essentialsOnly: 'Alleen essentieel',
1471
+ preferencesTitle: 'Cookie-voorkeuren',
1472
+ cookieSettings: 'Cookie-instellingen',
1473
+ cookies: 'Cookies',
1474
+ privacyPolicyLabel: 'Privacybeleid',
1475
+ };
1476
+ const pt = {
1477
+ title: 'Utilizamos cookies',
1478
+ description: 'Este site utiliza cookies para melhorar a sua experiência. Pode escolher quais cookies aceita.',
1479
+ acceptAll: 'Aceitar todos',
1480
+ rejectAll: 'Apenas essenciais',
1481
+ customize: 'Personalizar',
1482
+ savePreferences: 'Guardar',
1483
+ essentialsOnly: 'Apenas essenciais',
1484
+ preferencesTitle: 'Preferências de cookies',
1485
+ cookieSettings: 'Definições de cookies',
1486
+ cookies: 'Cookies',
1487
+ privacyPolicyLabel: 'Política de privacidade',
1488
+ };
1489
+ const builtInTranslations = {
1490
+ en,
1491
+ fr,
1492
+ de,
1493
+ es,
1494
+ it,
1495
+ nl,
1496
+ pt,
1497
+ };
1498
+
1315
1499
  /**
1316
1500
  * CookieConsent - Main orchestrator class
1317
1501
  */
@@ -1321,6 +1505,16 @@ class CookieConsent {
1321
1505
  this.preferenceCenter = null;
1322
1506
  this.floatingWidget = null;
1323
1507
  this.gtmIntegration = null;
1508
+ this.hideTimeout = null;
1509
+ // SSR guard
1510
+ if (typeof window === 'undefined') {
1511
+ this.config = config;
1512
+ this.consentManager = null;
1513
+ this.storageManager = null;
1514
+ this.eventEmitter = null;
1515
+ this.scriptBlocker = null;
1516
+ return;
1517
+ }
1324
1518
  this.config = this.validateConfig(config);
1325
1519
  this.consentManager = new ConsentManager(this.config);
1326
1520
  this.storageManager = new StorageManager();
@@ -1329,25 +1523,32 @@ class CookieConsent {
1329
1523
  if (this.config.gtmConsentMode) {
1330
1524
  this.gtmIntegration = new GTMConsentMode(new DataLayerManager(), this.config);
1331
1525
  }
1332
- // Listen for preference center requests
1333
- this.eventEmitter.on('preferences:show', () => {
1334
- this.showPreferences();
1335
- });
1336
- // Listen for consent updates
1526
+ // Listen for consent events — callbacks are fired AFTER consent is persisted
1337
1527
  this.eventEmitter.on('consent:accept', (categories) => {
1528
+ var _a, _b;
1338
1529
  this.updateConsent(categories);
1530
+ (_b = (_a = this.config).onAccept) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1339
1531
  });
1340
1532
  this.eventEmitter.on('consent:reject', (categories) => {
1533
+ var _a, _b;
1341
1534
  this.updateConsent(categories);
1535
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1342
1536
  });
1343
1537
  this.eventEmitter.on('consent:update', (categories) => {
1538
+ var _a, _b;
1344
1539
  this.updateConsent(categories);
1540
+ (_b = (_a = this.config).onChange) === null || _b === void 0 ? void 0 : _b.call(_a, categories);
1541
+ });
1542
+ this.eventEmitter.on('preferences:show', () => {
1543
+ this.showPreferences();
1345
1544
  });
1346
1545
  }
1347
1546
  /**
1348
1547
  * Initialize the cookie consent system
1349
1548
  */
1350
1549
  init() {
1550
+ if (typeof window === 'undefined')
1551
+ return;
1351
1552
  // 1. Start blocking scripts immediately
1352
1553
  this.scriptBlocker.init();
1353
1554
  // 2. Set GTM default consent BEFORE checking storage
@@ -1357,35 +1558,28 @@ class CookieConsent {
1357
1558
  // 3. Check for existing consent
1358
1559
  const storedConsent = this.storageManager.load();
1359
1560
  if (storedConsent && !this.storageManager.isExpired(storedConsent)) {
1360
- // Valid consent exists
1361
1561
  if (this.consentManager.needsUpdate(storedConsent)) {
1362
- // Policy updated, show banner again
1363
1562
  if (this.config.autoShow) {
1364
1563
  this.showBanner();
1365
1564
  }
1366
1565
  }
1367
1566
  else {
1368
- // Apply stored consent
1369
1567
  this.applyConsent(storedConsent.categories);
1370
- // Restore GTM consent for returning visitors (within wait_for_update window)
1371
1568
  if (this.gtmIntegration) {
1372
1569
  this.gtmIntegration.updateConsent(storedConsent.categories);
1373
1570
  }
1374
1571
  this.eventEmitter.emit('consent:load', storedConsent);
1375
- // Show floating widget if enabled
1376
1572
  if (this.config.showWidget) {
1377
1573
  this.showFloatingWidget();
1378
1574
  }
1379
1575
  }
1380
1576
  }
1381
1577
  else {
1382
- // No consent or expired
1383
1578
  if (this.config.autoShow) {
1384
1579
  this.showBanner();
1385
1580
  }
1386
1581
  }
1387
- // Store instance globally so it won't be garbage collected
1388
- // when used without a variable (e.g. new CookieConsent({}).init())
1582
+ // Store instance globally
1389
1583
  window.cookieConsent = this;
1390
1584
  this.eventEmitter.emit('consent:init');
1391
1585
  }
@@ -1408,14 +1602,18 @@ class CookieConsent {
1408
1602
  showPreferences() {
1409
1603
  var _a;
1410
1604
  const stored = (_a = this.storageManager.load()) === null || _a === void 0 ? void 0 : _a.categories;
1411
- // Default to all ON when no prior consent (user chose to customize)
1605
+ // Default to all ON when no prior consent
1412
1606
  const currentConsent = stored || {
1413
1607
  necessary: true,
1414
1608
  analytics: true,
1415
1609
  marketing: true,
1416
- preferences: true,
1417
1610
  };
1418
- // Always recreate to get fresh state from storage
1611
+ // Add any configured categories not in current consent
1612
+ for (const key of Object.keys(this.config.categories)) {
1613
+ if (!(key in currentConsent)) {
1614
+ currentConsent[key] = key === 'necessary';
1615
+ }
1616
+ }
1419
1617
  if (this.preferenceCenter) {
1420
1618
  this.preferenceCenter.destroy();
1421
1619
  }
@@ -1432,11 +1630,11 @@ class CookieConsent {
1432
1630
  if (this.gtmIntegration) {
1433
1631
  this.gtmIntegration.updateConsent(categories);
1434
1632
  }
1435
- // Show floating widget after consent is given (delay to let banner hide)
1633
+ // Show floating widget after consent is given
1436
1634
  if (this.config.showWidget) {
1437
1635
  setTimeout(() => {
1438
1636
  this.showFloatingWidget();
1439
- }, 400); // Wait for banner hide animation
1637
+ }, 400);
1440
1638
  }
1441
1639
  }
1442
1640
  /**
@@ -1449,14 +1647,19 @@ class CookieConsent {
1449
1647
  * Reset consent (clear stored data and show banner)
1450
1648
  */
1451
1649
  reset() {
1650
+ var _a, _b;
1452
1651
  this.storageManager.clear();
1453
1652
  this.scriptBlocker.block();
1454
- // Clear all non-essential cookies on reset
1455
- clearDeniedCookies({ necessary: true, analytics: false, marketing: false, preferences: false });
1456
- // Update GTM to denied state
1653
+ const denied = { necessary: true, analytics: false, marketing: false };
1654
+ for (const key of Object.keys(this.config.categories)) {
1655
+ if (key !== 'necessary')
1656
+ denied[key] = false;
1657
+ }
1658
+ clearDeniedCookies(denied);
1457
1659
  if (this.gtmIntegration) {
1458
- this.gtmIntegration.updateConsent({ necessary: true, analytics: false, marketing: false });
1660
+ this.gtmIntegration.updateConsent(denied);
1459
1661
  }
1662
+ (_b = (_a = this.config).onReject) === null || _b === void 0 ? void 0 : _b.call(_a);
1460
1663
  this.showBanner();
1461
1664
  }
1462
1665
  /**
@@ -1476,6 +1679,10 @@ class CookieConsent {
1476
1679
  */
1477
1680
  destroy() {
1478
1681
  var _a, _b, _c, _d;
1682
+ if (this.hideTimeout) {
1683
+ clearTimeout(this.hideTimeout);
1684
+ this.hideTimeout = null;
1685
+ }
1479
1686
  (_a = this.banner) === null || _a === void 0 ? void 0 : _a.destroy();
1480
1687
  this.banner = null;
1481
1688
  (_b = this.preferenceCenter) === null || _b === void 0 ? void 0 : _b.destroy();
@@ -1483,6 +1690,10 @@ class CookieConsent {
1483
1690
  (_c = this.floatingWidget) === null || _c === void 0 ? void 0 : _c.destroy();
1484
1691
  this.floatingWidget = null;
1485
1692
  (_d = this.scriptBlocker) === null || _d === void 0 ? void 0 : _d.destroy();
1693
+ this.eventEmitter.clear();
1694
+ if (window.cookieConsent === this) {
1695
+ window.cookieConsent = undefined;
1696
+ }
1486
1697
  }
1487
1698
  /**
1488
1699
  * Show the banner
@@ -1508,33 +1719,37 @@ class CookieConsent {
1508
1719
  */
1509
1720
  applyConsent(categories) {
1510
1721
  this.scriptBlocker.unblock(categories);
1511
- // CNIL/GDPR: actively delete cookies for denied categories
1512
1722
  clearDeniedCookies(categories);
1513
1723
  }
1514
1724
  /**
1515
1725
  * Validate and set default config values
1516
1726
  */
1517
1727
  validateConfig(config) {
1518
- return Object.assign(Object.assign({}, config), { categories: config.categories || {
1728
+ var _a;
1729
+ // Merge built-in language translations with user overrides
1730
+ const langKey = ((_a = config.language) === null || _a === void 0 ? void 0 : _a.toLowerCase().split('-')[0]) || 'en';
1731
+ const langDefaults = builtInTranslations[langKey] || builtInTranslations['en'];
1732
+ const mergedTranslations = Object.assign(Object.assign({}, langDefaults), config.translations);
1733
+ return Object.assign(Object.assign({}, config), { translations: mergedTranslations, categories: config.categories || {
1519
1734
  necessary: {
1520
1735
  enabled: true,
1521
1736
  readOnly: true,
1522
- label: 'Nécessaires',
1523
- description: 'Ces cookies sont indispensables au fonctionnement du site.',
1737
+ label: 'Essential',
1738
+ description: 'Required for the website to function properly.',
1524
1739
  },
1525
1740
  analytics: {
1526
1741
  enabled: true,
1527
1742
  readOnly: false,
1528
- label: 'Analytiques',
1529
- description: 'Ces cookies nous aident à comprendre comment vous utilisez le site.',
1743
+ label: 'Analytics',
1744
+ description: 'Help us understand how you use our site.',
1530
1745
  },
1531
1746
  marketing: {
1532
1747
  enabled: true,
1533
1748
  readOnly: false,
1534
1749
  label: 'Marketing',
1535
- description: 'Ces cookies sont utilisés pour vous proposer des publicités pertinentes.',
1750
+ description: 'Used to deliver relevant advertisements.',
1536
1751
  },
1537
- }, 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' });
1752
+ }, 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' });
1538
1753
  }
1539
1754
  }
1540
1755