fivo_cookie_consent 0.2.0

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.
@@ -0,0 +1,924 @@
1
+ /**
2
+ * GDPR Cookie Consent Manager
3
+ * Handles cookie consent banner display, user preferences, and localStorage management
4
+ */
5
+ class GDPRCookieConsent {
6
+ constructor() {
7
+ this.storageKey = 'gdpr_cookie_consent';
8
+ this.cookieLifespan = 365; // days
9
+ this.banner = null;
10
+ this.modal = null;
11
+ this.detectedCookies = {};
12
+ this.expandedCategories = new Set();
13
+
14
+ this.init();
15
+ }
16
+
17
+ /**
18
+ * Initialize the cookie consent manager
19
+ */
20
+ init() {
21
+ // Wait for DOM to be ready
22
+ if (document.readyState === 'loading') {
23
+ document.addEventListener('DOMContentLoaded', () => this.setup());
24
+ } else {
25
+ this.setup();
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Setup event listeners and check consent status
31
+ */
32
+ setup() {
33
+ this.banner = document.getElementById('gdpr-cookie-banner');
34
+ this.modal = document.getElementById('gdpr-cookie-modal');
35
+
36
+ if (!this.banner || !this.modal) {
37
+ console.warn('GDPR Cookie Consent: Banner or modal not found');
38
+ return;
39
+ }
40
+
41
+ this.setupEventListeners();
42
+ this.detectAndCategorizeInitialCookies();
43
+ this.checkConsentStatus();
44
+ this.setupCookieObserver();
45
+ }
46
+
47
+ /**
48
+ * Setup all event listeners for banner and modal interactions
49
+ */
50
+ setupEventListeners() {
51
+ // Banner buttons
52
+ this.banner.addEventListener('click', (e) => {
53
+ const action = e.target.dataset.action;
54
+ if (action === 'accept') {
55
+ this.acceptAll();
56
+ } else if (action === 'decline') {
57
+ this.declineAll();
58
+ } else if (action === 'settings') {
59
+ this.openModal();
60
+ }
61
+ });
62
+
63
+ // Modal interactions
64
+ this.modal.addEventListener('click', (e) => {
65
+ // Check the clicked element and its parents for data-action
66
+ let element = e.target;
67
+ let action = null;
68
+
69
+ // Traverse up the DOM to find an element with data-action
70
+ while (element && element !== this.modal) {
71
+ if (element.dataset && element.dataset.action) {
72
+ action = element.dataset.action;
73
+ break;
74
+ }
75
+ element = element.parentElement;
76
+ }
77
+
78
+ if (action === 'close-modal') {
79
+ e.preventDefault();
80
+ this.closeModal();
81
+ } else if (action === 'save-preferences') {
82
+ this.savePreferences();
83
+ } else if (action === 'decline-all') {
84
+ this.declineAll();
85
+ }
86
+ });
87
+
88
+ // Close modal on Escape key
89
+ document.addEventListener('keydown', (e) => {
90
+ if (e.key === 'Escape' && this.isModalOpen()) {
91
+ this.closeModal();
92
+ }
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Check if user has already made consent choices
98
+ */
99
+ checkConsentStatus() {
100
+ const consent = this.getConsent();
101
+ if (!consent) {
102
+ this.showBanner();
103
+ } else {
104
+ this.loadPreferences();
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Show the cookie consent banner
110
+ */
111
+ showBanner() {
112
+ if (this.banner) {
113
+ this.banner.style.display = 'block';
114
+ // Add animation class for better UX
115
+ setTimeout(() => {
116
+ this.banner.classList.add('gdpr-banner--visible');
117
+ }, 100);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Hide the cookie consent banner
123
+ */
124
+ hideBanner() {
125
+ if (this.banner) {
126
+ this.banner.classList.remove('gdpr-banner--visible');
127
+ setTimeout(() => {
128
+ this.banner.style.display = 'none';
129
+ }, 300);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Open the preferences modal
135
+ */
136
+ openModal() {
137
+ if (this.modal) {
138
+ this.modal.style.display = 'block';
139
+ document.body.style.overflow = 'hidden';
140
+ // Re-detect cookies in case they changed since initial load
141
+ this.detectAndCategorizeInitialCookies();
142
+
143
+ // Load current preferences into modal
144
+ this.loadPreferences();
145
+
146
+ // Setup category expansion functionality
147
+ this.setupCategoryExpansion();
148
+
149
+ // Focus management for accessibility
150
+ const firstInput = this.modal.querySelector('input[type="checkbox"]:not([disabled])');
151
+ if (firstInput) {
152
+ firstInput.focus();
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Close the preferences modal
159
+ */
160
+ closeModal() {
161
+ if (this.modal) {
162
+ this.modal.style.display = 'none';
163
+ document.body.style.overflow = '';
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Check if modal is currently open
169
+ */
170
+ isModalOpen() {
171
+ return this.modal && this.modal.style.display === 'block';
172
+ }
173
+
174
+ /**
175
+ * Accept all cookie categories
176
+ */
177
+ acceptAll() {
178
+ const consent = {
179
+ necessary: true,
180
+ analytics: true,
181
+ marketing: true,
182
+ functional: true,
183
+ savedAt: new Date().toISOString()
184
+ };
185
+
186
+ this.saveConsent(consent);
187
+ this.hideBanner();
188
+ this.closeModal();
189
+ this.dispatchEvent('gdpr:accept', { categories: ['analytics', 'marketing', 'functional'] });
190
+ this.dispatchEvent('gdpr:change', { consent: consent });
191
+ }
192
+
193
+ /**
194
+ * Decline all non-necessary cookies
195
+ */
196
+ declineAll() {
197
+ const consent = {
198
+ necessary: true,
199
+ analytics: false,
200
+ marketing: false,
201
+ functional: false,
202
+ savedAt: new Date().toISOString()
203
+ };
204
+
205
+ this.saveConsent(consent);
206
+ this.hideBanner();
207
+ this.closeModal();
208
+ this.dispatchEvent('gdpr:decline', { categories: ['analytics', 'marketing', 'functional'] });
209
+ this.dispatchEvent('gdpr:change', { consent: consent });
210
+ }
211
+
212
+ /**
213
+ * Save user preferences from modal
214
+ */
215
+ savePreferences() {
216
+ const consent = {
217
+ necessary: true,
218
+ analytics: this.getCheckboxValue('gdpr-analytics'),
219
+ marketing: this.getCheckboxValue('gdpr-marketing'),
220
+ functional: this.getCheckboxValue('gdpr-functional'),
221
+ savedAt: new Date().toISOString()
222
+ };
223
+
224
+ this.saveConsent(consent);
225
+ this.hideBanner();
226
+ this.closeModal();
227
+
228
+ // Dispatch events for accepted and declined categories
229
+ const accepted = [];
230
+ const declined = [];
231
+
232
+ ['analytics', 'marketing', 'functional'].forEach(category => {
233
+ if (consent[category]) {
234
+ accepted.push(category);
235
+ } else {
236
+ declined.push(category);
237
+ }
238
+ });
239
+
240
+ if (accepted.length > 0) {
241
+ this.dispatchEvent('gdpr:accept', { categories: accepted });
242
+ }
243
+ if (declined.length > 0) {
244
+ this.dispatchEvent('gdpr:decline', { categories: declined });
245
+ }
246
+
247
+ this.dispatchEvent('gdpr:change', { consent: consent });
248
+
249
+ // Re-detect cookies after saving preferences
250
+ setTimeout(() => {
251
+ this.detectAndCategorizeInitialCookies();
252
+ }, 100);
253
+ }
254
+
255
+ /**
256
+ * Load preferences into modal checkboxes
257
+ */
258
+ loadPreferences() {
259
+ const consent = this.getConsent();
260
+ if (!consent) return;
261
+
262
+ this.setCheckboxValue('gdpr-analytics', consent.analytics);
263
+ this.setCheckboxValue('gdpr-marketing', consent.marketing);
264
+ this.setCheckboxValue('gdpr-functional', consent.functional);
265
+ }
266
+
267
+ /**
268
+ * Get checkbox value by ID
269
+ */
270
+ getCheckboxValue(id) {
271
+ const checkbox = document.getElementById(id);
272
+ return checkbox ? checkbox.checked : false;
273
+ }
274
+
275
+ /**
276
+ * Set checkbox value by ID
277
+ */
278
+ setCheckboxValue(id, value) {
279
+ const checkbox = document.getElementById(id);
280
+ if (checkbox) {
281
+ checkbox.checked = value;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Save consent to localStorage
287
+ */
288
+ saveConsent(consent) {
289
+ try {
290
+ localStorage.setItem(this.storageKey, JSON.stringify(consent));
291
+ } catch (error) {
292
+ console.error('GDPR Cookie Consent: Failed to save consent to localStorage', error);
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Get consent from localStorage
298
+ */
299
+ getConsent() {
300
+ try {
301
+ const stored = localStorage.getItem(this.storageKey);
302
+ if (!stored) return null;
303
+
304
+ const consent = JSON.parse(stored);
305
+
306
+ // Check if consent is still valid (not expired)
307
+ if (consent.savedAt) {
308
+ const savedDate = new Date(consent.savedAt);
309
+ const expiryDate = new Date(savedDate.getTime() + (this.cookieLifespan * 24 * 60 * 60 * 1000));
310
+
311
+ if (new Date() > expiryDate) {
312
+ this.clearConsent();
313
+ return null;
314
+ }
315
+ }
316
+
317
+ return consent;
318
+ } catch (error) {
319
+ console.error('GDPR Cookie Consent: Failed to read consent from localStorage', error);
320
+ return null;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Clear consent from localStorage
326
+ */
327
+ clearConsent() {
328
+ try {
329
+ localStorage.removeItem(this.storageKey);
330
+ } catch (error) {
331
+ console.error('GDPR Cookie Consent: Failed to clear consent from localStorage', error);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Dispatch custom events for cookie consent changes
337
+ */
338
+ dispatchEvent(eventName, detail) {
339
+ const event = new CustomEvent(eventName, {
340
+ detail: detail,
341
+ bubbles: true,
342
+ cancelable: true
343
+ });
344
+
345
+ document.dispatchEvent(event);
346
+ }
347
+
348
+ /**
349
+ * Check if a specific category is consented to
350
+ */
351
+ hasConsent(category) {
352
+ const consent = this.getConsent();
353
+ return consent ? consent[category] === true : false;
354
+ }
355
+
356
+ /**
357
+ * Get all consent categories
358
+ */
359
+ getAllConsent() {
360
+ return this.getConsent();
361
+ }
362
+
363
+ /**
364
+ * Force show banner (useful for testing or settings page)
365
+ */
366
+ showBannerForced() {
367
+ this.showBanner();
368
+ }
369
+
370
+ /**
371
+ * Force show modal (useful for settings page)
372
+ */
373
+ showModalForced() {
374
+ this.openModal();
375
+ }
376
+
377
+ /**
378
+ * Detect and categorize cookies initially
379
+ */
380
+ detectAndCategorizeInitialCookies() {
381
+ // Get client-side detectable cookies
382
+ this.detectedCookies = this.detectCookies();
383
+
384
+ // Merge with server-side cookies
385
+ this.mergeServerSideCookies();
386
+
387
+ this.updateCookieDisplay();
388
+ }
389
+
390
+ /**
391
+ * Merge server-side cookies (HttpOnly) with client-side detected cookies
392
+ */
393
+ mergeServerSideCookies() {
394
+ if (!this.banner) return;
395
+
396
+ try {
397
+ const serverCookiesData = this.banner.dataset.serverCookies;
398
+ if (!serverCookiesData) return;
399
+
400
+ const serverCookies = JSON.parse(serverCookiesData);
401
+
402
+ // Merge server-side cookies into detected cookies
403
+ Object.keys(serverCookies).forEach(category => {
404
+ if (this.detectedCookies[category] && serverCookies[category]) {
405
+ // Add server-side cookies to the category, avoiding duplicates
406
+ serverCookies[category].forEach(serverCookie => {
407
+ const exists = this.detectedCookies[category].some(cookie =>
408
+ cookie.name === serverCookie.name
409
+ );
410
+
411
+ if (!exists) {
412
+ this.detectedCookies[category].push(serverCookie);
413
+ }
414
+ });
415
+ }
416
+ });
417
+ } catch (error) {
418
+ console.warn('GDPR: Failed to parse server-side cookies', error);
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Set up cookie observer to detect new cookies
424
+ */
425
+ setupCookieObserver() {
426
+ // Simple interval-based observer for cookie changes
427
+ setInterval(() => {
428
+ // Detect client-side cookies first
429
+ const newCookies = this.detectCookies();
430
+
431
+ // Clone so we don’t accidentally mutate the comparison object
432
+ this.detectedCookies = JSON.parse(JSON.stringify(newCookies));
433
+
434
+ // Always merge the server-side (HttpOnly) cookies so they persist
435
+ this.mergeServerSideCookies();
436
+
437
+ // If the overall cookie snapshot changed, re-render the UI
438
+ if (JSON.stringify(this.detectedCookies) !== JSON.stringify(newCookies)) {
439
+ this.updateCookieDisplay();
440
+ this.updateCategoryStates();
441
+ }
442
+ }, 2000); // Check every 2 seconds
443
+ }
444
+
445
+ /**
446
+ * Detect cookies and categorize them
447
+ * @returns {Object} Categorized cookies object
448
+ */
449
+ detectCookies() {
450
+ const cookies = {};
451
+ const categorized = {
452
+ necessary: [],
453
+ analytics: [],
454
+ marketing: [],
455
+ functional: [],
456
+ uncategorised: []
457
+ };
458
+
459
+ // Parse document.cookie
460
+ if (document.cookie && document.cookie.trim()) {
461
+ document.cookie.split(';').forEach(cookie => {
462
+ const trimmed = cookie.trim();
463
+ if (trimmed) {
464
+ const [name, ...valueParts] = trimmed.split('=');
465
+ const value = valueParts.join('='); // Handle values with = in them
466
+
467
+ if (name && name.trim()) {
468
+ const cookieName = name.trim();
469
+ cookies[cookieName] = {
470
+ name: cookieName,
471
+ value: value ? value.trim() : '',
472
+ duration: this.getCookieDuration(cookieName),
473
+ description: this.getCookieDescription(cookieName)
474
+ };
475
+ }
476
+ }
477
+ });
478
+ }
479
+
480
+ // Categorize cookies based on patterns
481
+ Object.values(cookies).forEach(cookie => {
482
+ const category = this.categorizeCookie(cookie.name);
483
+ categorized[category].push(cookie);
484
+ });
485
+
486
+ return categorized;
487
+ }
488
+
489
+ /**
490
+ * Categorize a cookie based on its name
491
+ * @param {string} cookieName - Name of the cookie
492
+ * @returns {string} Category name
493
+ */
494
+ categorizeCookie(cookieName) {
495
+ const patterns = this.getCookiePatterns();
496
+
497
+ for (const [category, regexList] of Object.entries(patterns)) {
498
+ for (const pattern of regexList) {
499
+ if (cookieName.match(pattern)) {
500
+ return category;
501
+ }
502
+ }
503
+ }
504
+ return 'uncategorised';
505
+ }
506
+
507
+ /**
508
+ * Get cookie patterns from configuration
509
+ * @returns {Object} Cookie patterns object
510
+ */
511
+ getCookiePatterns() {
512
+ // Default patterns - in production these would come from Ruby config
513
+ return {
514
+ necessary: [
515
+ /^_session_/,
516
+ /^session_/,
517
+ /^csrf_token/,
518
+ /^authenticity_token/,
519
+ /^_rails_/,
520
+ /^gdpr_cookie_consent$/
521
+ ],
522
+ analytics: [
523
+ /^_ga/,
524
+ /^_gid/,
525
+ /^_gat/,
526
+ /^_gtag/,
527
+ /^__utm/,
528
+ /^_dc_gtm_/,
529
+ /^_hjid/,
530
+ /^_hjFirstSeen/,
531
+ /^_hj/
532
+ ],
533
+ marketing: [
534
+ /^_fbp/,
535
+ /^_fbc/,
536
+ /^fr$/,
537
+ /^tr$/,
538
+ /^_pinterest_/,
539
+ /^_pin_unauth/,
540
+ /^__Secure-3PAPISID/,
541
+ /^__Secure-3PSID/,
542
+ /^NID$/,
543
+ /^IDE$/
544
+ ],
545
+ functional: [
546
+ /^_locale/,
547
+ /^language/,
548
+ /^theme/,
549
+ /^preferences/,
550
+ /^_user_settings/,
551
+ /^_ui_/
552
+ ]
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Get cookie duration (simplified)
558
+ * @param {string} cookieName - Name of the cookie
559
+ * @returns {string} Duration description
560
+ */
561
+ getCookieDuration(cookieName) {
562
+ // Simplified duration detection
563
+ if (cookieName.match(/^_session_|^session_/)) {
564
+ return 'Session';
565
+ }
566
+ if (cookieName.match(/^_ga/)) {
567
+ return '2 Jahre';
568
+ }
569
+ if (cookieName.match(/^_gid/)) {
570
+ return '24 Stunden';
571
+ }
572
+ return 'Unbekannt';
573
+ }
574
+
575
+ /**
576
+ * Get cookie description
577
+ * @param {string} cookieName - Name of the cookie
578
+ * @returns {string} Cookie description
579
+ */
580
+ getCookieDescription(cookieName) {
581
+ const descriptions = {
582
+ '_ga': 'Google Analytics Hauptcookie',
583
+ '_gid': 'Google Analytics Sitzungs-ID',
584
+ '_gat': 'Google Analytics Drosselung',
585
+ '_fbp': 'Facebook Pixel Browser-ID',
586
+ '_fbc': 'Facebook Click-ID'
587
+ };
588
+
589
+ return descriptions[cookieName] || '';
590
+ }
591
+
592
+ /**
593
+ * Update cookie display in modal
594
+ */
595
+ updateCookieDisplay() {
596
+ if (!this.modal) return;
597
+
598
+ const categories = ['necessary', 'analytics', 'marketing', 'functional'];
599
+
600
+ categories.forEach(category => {
601
+ this.updateCategoryDisplay(category);
602
+ });
603
+
604
+ // Show uncategorised cookies only in development mode
605
+ if (this.isDevelopmentMode() && this.detectedCookies.uncategorised.length > 0) {
606
+ this.showUncategorisedCookies();
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Update individual category display
612
+ * @param {string} category - Category name
613
+ */
614
+ updateCategoryDisplay(category) {
615
+ const categoryElement = this.modal.querySelector(`[data-category-section="${category}"]`);
616
+ if (!categoryElement) return;
617
+
618
+ const cookies = this.detectedCookies[category] || [];
619
+ const cookieListElement = categoryElement.querySelector('.gdpr-modal__cookie-list');
620
+ const emptyStateElement = categoryElement.querySelector('.gdpr-modal__empty-state');
621
+ const toggleElement = categoryElement.querySelector('input[type="checkbox"]');
622
+ const chevronElement = categoryElement.querySelector('.gdpr-modal__chevron');
623
+
624
+ // Determine expansion state (default collapsed)
625
+ const isExpanded = this.expandedCategories.has(category);
626
+
627
+ // Update empty state
628
+ if (cookies.length === 0) {
629
+ if (emptyStateElement) {
630
+ emptyStateElement.style.display = 'block';
631
+ }
632
+ if (cookieListElement) {
633
+ cookieListElement.style.display = 'none';
634
+ }
635
+
636
+ // Hide chevron for empty categories
637
+ if (chevronElement) {
638
+ chevronElement.style.display = 'none';
639
+ }
640
+ } else {
641
+ if (emptyStateElement) {
642
+ emptyStateElement.style.display = 'none';
643
+ }
644
+ if (cookieListElement) {
645
+ // Always (re)render table contents so data is fresh
646
+ this.renderCookieTable(cookieListElement, cookies);
647
+
648
+ // Show or hide list based on expansion state
649
+ cookieListElement.style.display = isExpanded ? 'block' : 'none';
650
+ }
651
+
652
+ // Enable toggle for non-empty categories
653
+ if (toggleElement && category !== 'necessary') {
654
+ toggleElement.disabled = false;
655
+ toggleElement.removeAttribute('aria-disabled');
656
+ const toggleContainer = toggleElement.closest('.gdpr-modal__toggle');
657
+ if (toggleContainer) {
658
+ toggleContainer.classList.remove('gdpr-modal__toggle--disabled');
659
+ }
660
+ }
661
+
662
+ // Show chevron for non-empty categories and set orientation
663
+ if (chevronElement) {
664
+ chevronElement.style.display = 'inline';
665
+ chevronElement.textContent = isExpanded ? '▾' : '▶';
666
+ chevronElement.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
667
+ }
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Render cookie table
673
+ * @param {Element} container - Container element
674
+ * @param {Array} cookies - Array of cookie objects
675
+ */
676
+ renderCookieTable(container, cookies) {
677
+ const table = document.createElement('table');
678
+ table.className = 'gdpr-modal__cookie-table';
679
+
680
+ // Table header
681
+ const header = document.createElement('thead');
682
+ header.innerHTML = `
683
+ <tr>
684
+ <th>Name</th>
685
+ <th>Dauer</th>
686
+ <th>Beschreibung</th>
687
+ </tr>
688
+ `;
689
+ table.appendChild(header);
690
+
691
+ // Table body
692
+ const body = document.createElement('tbody');
693
+ cookies.forEach(cookie => {
694
+ const row = document.createElement('tr');
695
+ row.innerHTML = `
696
+ <td><code>${this.escapeHtml(cookie.name)}</code></td>
697
+ <td>${this.escapeHtml(cookie.duration)}</td>
698
+ <td>${this.escapeHtml(cookie.description)}</td>
699
+ `;
700
+ body.appendChild(row);
701
+ });
702
+ table.appendChild(body);
703
+
704
+ container.innerHTML = '';
705
+ container.appendChild(table);
706
+ }
707
+
708
+ /**
709
+ * Update category states based on detected cookies
710
+ */
711
+ updateCategoryStates() {
712
+ // Re-enable toggles that now have cookies
713
+ const categories = ['analytics', 'marketing', 'functional'];
714
+
715
+ categories.forEach(category => {
716
+ const cookies = this.detectedCookies[category] || [];
717
+ const toggleElement = this.modal?.querySelector(`#gdpr-${category}`);
718
+
719
+ if (toggleElement && cookies.length > 0) {
720
+ toggleElement.disabled = false;
721
+ toggleElement.removeAttribute('aria-disabled');
722
+ const toggleContainer = toggleElement.closest('.gdpr-modal__toggle');
723
+ if (toggleContainer) {
724
+ toggleContainer.classList.remove('gdpr-modal__toggle--disabled');
725
+ }
726
+ }
727
+ });
728
+ }
729
+
730
+ /**
731
+ * Check if in development mode
732
+ * @returns {boolean} True if in development mode
733
+ */
734
+ isDevelopmentMode() {
735
+ return window.location.hostname === 'localhost' ||
736
+ window.location.hostname === '127.0.0.1' ||
737
+ window.location.hostname.includes('dev');
738
+ }
739
+
740
+ /**
741
+ * Show uncategorised cookies section
742
+ */
743
+ showUncategorisedCookies() {
744
+ // Implementation would add a special section for uncategorised cookies
745
+ console.log('Uncategorised cookies detected:', this.detectedCookies.uncategorised);
746
+ }
747
+
748
+ /**
749
+ * Setup category expansion functionality
750
+ */
751
+ setupCategoryExpansion() {
752
+ if (!this.modal) return;
753
+
754
+ // Add click handlers for category headers with chevrons
755
+ this.modal.addEventListener('click', (e) => {
756
+ // Allow click either on chevron or entire header row
757
+ const header = e.target.closest('.gdpr-modal__category-header');
758
+ if (header) {
759
+ const category = header.dataset.category;
760
+ if (category) {
761
+ this.toggleCategoryExpansion(category);
762
+ }
763
+ }
764
+ });
765
+ }
766
+
767
+ /**
768
+ * Toggle category expansion
769
+ * @param {string} category - Category to toggle
770
+ */
771
+ toggleCategoryExpansion(category) {
772
+ const categoryElement = this.modal.querySelector(`[data-category-section="${category}"]`);
773
+ const cookieListElement = categoryElement?.querySelector('.gdpr-modal__cookie-list');
774
+ const chevronElement = categoryElement?.querySelector('.gdpr-modal__chevron');
775
+ const cookies = this.detectedCookies[category] || [];
776
+
777
+ // Do not expand categories that currently have no cookies; only show the empty state
778
+ if (!categoryElement || !cookieListElement || cookies.length === 0) {
779
+ return;
780
+ }
781
+
782
+ const isExpanded = this.expandedCategories.has(category);
783
+
784
+ if (isExpanded) {
785
+ // Collapse
786
+ cookieListElement.style.display = 'none';
787
+ chevronElement.textContent = '▶';
788
+ chevronElement.setAttribute('aria-expanded', 'false');
789
+ this.expandedCategories.delete(category);
790
+ } else {
791
+ // Expand
792
+ cookieListElement.style.display = 'block';
793
+ chevronElement.textContent = '▾';
794
+ chevronElement.setAttribute('aria-expanded', 'true');
795
+ this.expandedCategories.add(category);
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Escape HTML for safe rendering
801
+ * @param {string} text - Text to escape
802
+ * @returns {string} Escaped text
803
+ */
804
+ escapeHtml(text) {
805
+ const div = document.createElement('div');
806
+ div.textContent = text;
807
+ return div.innerHTML;
808
+ }
809
+ }
810
+
811
+ // Auto-initialize when module is loaded
812
+ let gdprInstance = null;
813
+
814
+ // Initialize GDPR Cookie Consent
815
+ function initGDPRCookieConsent() {
816
+ if (!gdprInstance) {
817
+ gdprInstance = new GDPRCookieConsent();
818
+ }
819
+ return gdprInstance;
820
+ }
821
+
822
+ function processGdprPlaceholders() {
823
+ const instance = initGDPRCookieConsent();
824
+ const placeholders = document.querySelectorAll(
825
+ 'script[type="text/plain"][data-gdpr-category]:not([data-gdpr-loaded="true"])'
826
+ );
827
+
828
+ placeholders.forEach((element) => {
829
+ const category = element.dataset.gdprCategory;
830
+ if (!category || !instance.hasConsent(category)) {
831
+ return;
832
+ }
833
+
834
+ const src = element.dataset.gdprSrc;
835
+ const inlineCode = element.textContent && element.textContent.trim();
836
+
837
+ if (!src && !inlineCode) {
838
+ element.dataset.gdprLoaded = 'true';
839
+ return;
840
+ }
841
+
842
+ const script = document.createElement('script');
843
+
844
+ if (src) {
845
+ script.src = src;
846
+ }
847
+
848
+ if (element.dataset.gdprAsync === 'true') {
849
+ script.async = true;
850
+ }
851
+
852
+ if (element.dataset.gdprDefer === 'true') {
853
+ script.defer = true;
854
+ }
855
+
856
+ if (!src && inlineCode) {
857
+ script.text = inlineCode;
858
+ }
859
+
860
+ script.setAttribute('data-gdpr-origin', 'fivo_cookie_consent');
861
+
862
+ if (element.parentNode) {
863
+ element.parentNode.insertBefore(script, element);
864
+ }
865
+
866
+ element.dataset.gdprLoaded = 'true';
867
+ });
868
+ }
869
+
870
+ function bootstrapGdprScriptLoading() {
871
+ if (document.readyState === 'loading') {
872
+ document.addEventListener('DOMContentLoaded', processGdprPlaceholders);
873
+ } else {
874
+ processGdprPlaceholders();
875
+ }
876
+
877
+ document.addEventListener('gdpr:accept', processGdprPlaceholders);
878
+ document.addEventListener('gdpr:change', processGdprPlaceholders);
879
+ }
880
+
881
+ // Export for manual initialization if needed
882
+ const GDPRConsent = {
883
+ getConsent() {
884
+ return initGDPRCookieConsent().getAllConsent();
885
+ },
886
+ hasConsent(category) {
887
+ return initGDPRCookieConsent().hasConsent(category);
888
+ },
889
+ on(eventName, handler) {
890
+ if (!eventName || typeof handler !== 'function') {
891
+ return;
892
+ }
893
+ document.addEventListener(eventName, handler);
894
+ },
895
+ off(eventName, handler) {
896
+ if (!eventName || typeof handler !== 'function') {
897
+ return;
898
+ }
899
+ document.removeEventListener(eventName, handler);
900
+ },
901
+ reset() {
902
+ const instance = initGDPRCookieConsent();
903
+ instance.clearConsent();
904
+ instance.showBannerForced();
905
+ },
906
+ showBanner() {
907
+ initGDPRCookieConsent().showBannerForced();
908
+ },
909
+ showModal() {
910
+ initGDPRCookieConsent().showModalForced();
911
+ }
912
+ };
913
+
914
+ window.GDPRCookieConsent = GDPRCookieConsent;
915
+ window.initGDPRCookieConsent = initGDPRCookieConsent;
916
+ window.GDPRConsent = GDPRConsent;
917
+
918
+ // Auto-initialize
919
+ initGDPRCookieConsent();
920
+ bootstrapGdprScriptLoading();
921
+
922
+ // Export for ES6 modules
923
+ export default GDPRCookieConsent;
924
+ export { initGDPRCookieConsent, GDPRConsent };