PageSourceSearch

https://www.sander-dickkopp.de/cookie-consent.js

js sander-dickkopp.de collected 2026-10-02 16:37:47 UTC 42,118 bytes, 1,095 lines download raw bytes

1/**
2 * Cookie-Consent — Sander Dickkopp
3 * DSGVO + TDDDG (2025) konform
4 *
5 * Kategorien:
6 *   - notwendig (immer aktiv, nicht abwählbar)
7 *   - analyse   (GA4 + Microsoft Clarity)
8 *   - marketing (Meta Pixel + OpenAI Ads Pixel)
9 *   - karten    (Google Maps)
10 *
11 * Speicher: localStorage Key "sd_consent"
12 * Kein externer Request vor Einwilligung.
13 */
14
15(function () {
16  'use strict';
17
18  // =============================================
19  // Konfiguration — Platzhalter hier anpassen
20  // =============================================
21  var CONFIG = {
22    GA4_ID: 'G-RR19NCPGFV',
23    META_PIXEL_ID: '1209693480252470',
24    OAI_PIXEL_ID: 'S3geYfL6kk3MdsArUu4jr8',
25    CLARITY_ID: 'wxn0ktdl32',
26    STORAGE_KEY: 'sd_consent',
27    LOG_KEY: 'sd_consent_log',
28    // Consent-Gültigkeit in Tagen (DSGVO-Empfehlung: max 12 Monate)
29    EXPIRY_DAYS: 365,
30    // Consent-Version: Änderung macht alle älteren Consents ungültig.
31    // v1 = vor DSGVO-Fix (inline Pixel feuerte vor Consent — ungültig)
32    // v2 = nach DSGVO-Fix 2026-03-24 — alle alten Consents werden invalidiert
33    // v3 = 2026-06-09: Marketing umfasst jetzt auch Google-Ads-Signale
34    //      (ad_storage/ad_user_data/ad_personalization, Consent Mode v2). Erweiterter
35    //      Einwilligungs-Umfang → alte Consents invalidieren + neu einholen.
36    //      OpenAI Ads Pixel. Erweiterter Einwilligungs-Umfang -> neu einholen.
37    CONSENT_VERSION: 'v4'
38  };
39
40  // =============================================
41  // Hilfsfunktionen
42  // =============================================
43
44  /** Consent aus localStorage lesen */
45  function getConsent() {
46    try {
47      var raw = localStorage.getItem(CONFIG.STORAGE_KEY);
48      if (!raw) return null;
49      var data = JSON.parse(raw);
50      // Prüfe Consent-Version — veraltete Consents (vor DSGVO-Fix) invalidieren
51      if (data.version !== CONFIG.CONSENT_VERSION) {
52        localStorage.removeItem(CONFIG.STORAGE_KEY);
53        return null;
54      }
55      // Prüfe ob Consent abgelaufen ist
56      if (data.timestamp) {
57        var age = Date.now() - data.timestamp;
58        if (age > CONFIG.EXPIRY_DAYS * 24 * 60 * 60 * 1000) {
59          localStorage.removeItem(CONFIG.STORAGE_KEY);
60          return null;
61        }
62      }
63      return data;
64    } catch (e) {
65      return null;
66    }
67  }
68
69  /** Consent in localStorage speichern + Log schreiben */
70  function saveConsent(categories) {
71    var data = {
72      version: CONFIG.CONSENT_VERSION,
73      timestamp: Date.now(),
74      date: new Date().toISOString(),
75      categories: categories
76    };
77    localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(data));
78    // Consent-Log anhängen
79    logConsent(data);
80  }
81
82  /** Consent-Änderungen lokal protokollieren */
83  function logConsent(data) {
84    try {
85      var logs = JSON.parse(localStorage.getItem(CONFIG.LOG_KEY) || '[]');
86      logs.push({
87        date: data.date,
88        categories: data.categories
89      });
90      // Max. 50 Einträge behalten
91      if (logs.length > 50) logs = logs.slice(-50);
92      localStorage.setItem(CONFIG.LOG_KEY, JSON.stringify(logs));
93    } catch (e) {
94      // Stilles Fehler-Handling
95    }
96  }
97
98  // =============================================
99  // First-Touch-Attribution (Lead-Quelle pro Anfrage)
100  // Erfasst beim ERSTEN Besuch die Quelle (utm_* aus URL, sonst gclid/fbclid,
101  // sonst Referrer) und persistiert sie 90 Tage. Bei jedem Formular-Submit
102  // werden die vorhandenen hidden utm_*-Felder daraus befuellt (falls leer)
103  // -> landet in interner Mail + DB. So bleibt die Quelle auch bei interner
104  // Navigation / verzoegerter Anfrage erhalten (loest das "UTM weg"-Leck).
105  // DSGVO/TDDDG: NUR bei Marketing-Einwilligung gespeichert (wie Pixel/GA4).
106  // =============================================
107
108  var ATTR_KEY = 'sd_first_touch';
109  var ATTR_DAYS = 90;
110
111  function attrHost(url) {
112    try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); } catch (e) { return ''; }
113  }
114
115  /** Ermittelt die Quelle des aktuellen Besuchs. null = reine interne Navigation. */
116  function computeRawAttribution() {
117    var p = new URLSearchParams(location.search);
118    if (p.get('utm_source')) {
119      return {
120        utm_source: p.get('utm_source'),
121        utm_medium: p.get('utm_medium') || '',
122        utm_campaign: p.get('utm_campaign') || '',
123        utm_content: p.get('utm_content') || ''
124      };
125    }
126    if (p.get('gclid') || p.get('gbraid') || p.get('wbraid')) {
127      return { utm_source: 'google', utm_medium: 'cpc', utm_campaign: '(gclid)', utm_content: '' };
128    }
129    if (p.get('msclkid')) {
130      return { utm_source: 'bing', utm_medium: 'cpc', utm_campaign: '(msclkid)', utm_content: '' };
131    }
132    if (p.get('fbclid')) {
133      return { utm_source: 'facebook', utm_medium: 'paid-social', utm_campaign: '(fbclid)', utm_content: '' };
134    }
135    var host = attrHost(document.referrer);
136    if (!host) return { utm_source: '(direct)', utm_medium: '(none)', utm_campaign: '', utm_content: '' };
137    if (host.indexOf('sander-dickkopp') !== -1) return null; // interne Navigation -> First-Touch behalten
138    if (/google\./.test(host)) return { utm_source: 'google', utm_medium: 'organic', utm_campaign: '', utm_content: '' };
139    if (/bing\.|duckduckgo|ecosia|yahoo|yandex|startpage/.test(host)) return { utm_source: host, utm_medium: 'organic', utm_campaign: '', utm_content: '' };
140    if (/facebook|instagram|fb\.me|l\.facebook|lm\.facebook/.test(host)) return { utm_source: 'facebook', utm_medium: 'social', utm_campaign: '', utm_content: '' };
141    if (/t\.co|twitter|x\.com/.test(host)) return { utm_source: 'twitter', utm_medium: 'social', utm_campaign: '', utm_content: '' };
142    if (/linkedin/.test(host)) return { utm_source: 'linkedin', utm_medium: 'social', utm_campaign: '', utm_content: '' };
143    return { utm_source: host, utm_medium: 'referral', utm_campaign: '', utm_content: '' };
144  }
145
146  /** Liest die persistierte First-Touch-Quelle (oder null, bei Ablauf/leer). */
147  function getStoredAttribution() {
148    try {
149      var raw = localStorage.getItem(ATTR_KEY);
150      if (!raw) return null;
151      var d = JSON.parse(raw);
152      if (!d || !d.ts || (Date.now() - d.ts) > ATTR_DAYS * 86400000) { localStorage.removeItem(ATTR_KEY); return null; }
153      return d;
154    } catch (e) { return null; }
155  }
156
157  /** Setzt First-Touch EINMAL (bleibt 90 Tage). Nur bei Marketing-Consent. */
158  function persistFirstTouch(cats) {
159    try {
160      if (!cats) { var c = getConsent(); cats = c && c.categories; }
161      if (!cats || !cats.marketing) return;
162      if (getStoredAttribution()) return; // First-Touch schon gesetzt
163      var raw = computeRawAttribution();
164      if (!raw) raw = { utm_source: '(direct)', utm_medium: '(none)', utm_campaign: '', utm_content: '' };
165      raw.ts = Date.now();
166      raw.landing = (location.pathname || '').slice(0, 120);
167      localStorage.setItem(ATTR_KEY, JSON.stringify(raw));
168    } catch (e) { /* still */ }
169  }
170
171  /** Befuellt alle LEEREN hidden utm_*-Felder auf der Seite aus First-Touch. */
172  function fillAttributionFields(root) {
173    var attr = getStoredAttribution();
174    if (!attr) return;
175    var map = { utm_source: attr.utm_source, utm_medium: attr.utm_medium, utm_campaign: attr.utm_campaign, utm_content: attr.utm_content };
176    Object.keys(map).forEach(function (name) {
177      var nodes = (root || document).querySelectorAll('input[name="' + name + '"]');
178      for (var i = 0; i < nodes.length; i++) { if (!nodes[i].value) nodes[i].value = map[name] || ''; }
179    });
180  }
181
182  // =============================================
183  // Dienste dynamisch nachladen
184  // =============================================
185
186  var servicesLoaded = { analyse: false, marketing: false, karten: false, clarity: false, oai: false };
187
188  // =============================================
189  // GA4 Consent Mode v2 — frühe Initialisierung
190  // dataLayer + gtag müssen vor dem gtag.js-Script
191  // definiert sein, damit Consent Mode korrekt greift.
192  // Wir setzen alles standardmäßig auf "denied".
193  // =============================================
194  window.dataLayer = window.dataLayer || [];
195  function gtag() { window.dataLayer.push(arguments); }
196  window.gtag = gtag;
197
198  // Consent Mode v2: Standardmäßig alles verweigert
199  gtag('consent', 'default', {
200    analytics_storage: 'denied',
201    ad_storage: 'denied',
202    ad_user_data: 'denied',
203    ad_personalization: 'denied',
204    wait_for_update: 500
205  });
206
207  /** GA4 laden — nur nach Einwilligung (Kategorie: analyse) */
208  function loadGA4() {
209    if (servicesLoaded.analyse) return;
210    servicesLoaded.analyse = true;
211
212    // Consent Mode v2: Speicherung jetzt erlaubt
213    gtag('consent', 'update', {
214      analytics_storage: 'granted'
215    });
216
217    // gtag.js Script dynamisch injizieren
218    var script = document.createElement('script');
219    script.async = true;
220    script.src = 'https://www.googletagmanager.com/gtag/js?id=' + CONFIG.GA4_ID;
221    document.head.appendChild(script);
222
223    gtag('js', new Date());
224    gtag('config', CONFIG.GA4_ID, { anonymize_ip: true });
225  }
226
227  /** GA4 Widerruf — Consent zurückziehen */
228  function revokeGA4() {
229    gtag('consent', 'update', {
230      analytics_storage: 'denied',
231      ad_storage: 'denied',
232      ad_user_data: 'denied',
233      ad_personalization: 'denied'
234    });
235  }
236
237  /**
238   * Google-Ads-Einwilligungssignale freigeben (Consent Mode v2).
239   * Wird NUR aufgerufen, wenn der Google-Tag ohnehin geladen ist (Kategorie analyse)
240   * UND Marketing zugestimmt wurde. Lädt KEINEN Google-Request vor Einwilligung —
241   * bleibt DSGVO-konform (kein externer Call vor Consent). Behebt die degradierte
242   * Google-Ads-Conversion-Messung: bisher wurde nur analytics_storage granted.
243   */
244  function grantAdsConsent() {
245    gtag('consent', 'update', {
246      ad_storage: 'granted',
247      ad_user_data: 'granted',
248      ad_personalization: 'granted'
249    });
250  }
251
252  /** Meta Pixel laden — nur nach Einwilligung (Kategorie: marketing) */
253  function loadMetaPixel() {
254    if (servicesLoaded.marketing) return;
255    servicesLoaded.marketing = true;
256
257    // fbevents.js Script dynamisch injizieren
258    !function (f, b, e, v, n, t, s) {
259      if (f.fbq) return;
260      n = f.fbq = function () { n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments); };
261      if (!f._fbq) f._fbq = n;
262      n.push = n; n.loaded = true; n.version = '2.0';
263      n.queue = [];
264      t = b.createElement(e); t.async = true; t.src = v;
265      s = b.getElementsByTagName(e)[0]; s.parentNode.insertBefore(t, s);
266    }(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js');
267
268    window.fbq('init', CONFIG.META_PIXEL_ID);
269    window.fbq('track', 'PageView');
270
271    // Noscript-Fallback dynamisch in den body injizieren
272    // (für Nutzer ohne JavaScript — ebenfalls nur nach Einwilligung)
273    if (!document.getElementById('sd-meta-noscript')) {
274      var ns = document.createElement('noscript');
275      ns.id = 'sd-meta-noscript';
276      var img = document.createElement('img');
277      img.height = 1;
278      img.width = 1;
279      img.style.display = 'none';
280      img.src = 'https://www.facebook.com/tr?id=' + CONFIG.META_PIXEL_ID + '&ev=PageView&noscript=1';
281      ns.appendChild(img);
282      document.body.appendChild(ns);
283    }
284  }
285
286  /** Meta Pixel Widerruf */
287  function revokeMetaPixel() {
288    if (window.fbq) {
289      window.fbq('consent', 'revoke');
290    }
291  }
292  /** OpenAI Ads Pixel laden, nur nach Einwilligung (Kategorie: marketing) */
293  function loadOAIPixel() {
294    if (servicesLoaded.oai) return;
295    servicesLoaded.oai = true;
296
297    (function (w, d, s, u) {
298      if (w.oaiq) return;
299      var q = function () { q.q.push(arguments); };
300      q.q = [];
301      w.oaiq = q;
302      var js = d.createElement(s);
303      js.async = true;
304      js.src = u;
305      var f = d.getElementsByTagName(s)[0];
306      f.parentNode.insertBefore(js, f);
307    })(window, document, 'script', 'https://bzrcdn.openai.com/sdk/oaiq.min.js');
308
309    window.oaiq('consent', true);
310    window.oaiq('init', { pixelId: CONFIG.OAI_PIXEL_ID });
311    window.oaiq('measure', 'page_viewed', {
312      type: 'contents',
313      contents: [{ id: location.pathname, name: document.title, content_type: 'page' }]
314    });
315  }
316
317  /** OpenAI Ads Pixel Widerruf */
318  function revokeOAIPixel() {
319    if (window.oaiq) {
320      window.oaiq('consent', false);
321    }
322  }
323
324  /** Google Maps Platzhalter aktivieren */
325  function activateMaps() {
326    if (servicesLoaded.karten) return;
327    servicesLoaded.karten = true;
328
329    // Alle Platzhalter durch echte Iframes ersetzen
330    var placeholders = document.querySelectorAll('[data-cc-maps]');
331    placeholders.forEach(function (el) {
332      var src = el.getAttribute('data-cc-maps');
333      var w = el.getAttribute('data-cc-width') || '100%';
334      var h = el.getAttribute('data-cc-height') || '400';
335
336      var iframe = document.createElement('iframe');
337      iframe.src = src;
338      iframe.width = w;
339      iframe.height = h;
340      iframe.style.border = '0';
341      iframe.style.borderRadius = '12px';
342      iframe.setAttribute('allowfullscreen', '');
343      iframe.setAttribute('loading', 'lazy');
344      iframe.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
345
346      el.parentNode.replaceChild(iframe, el);
347    });
348  }
349
350  /** Microsoft Clarity laden — nur nach Einwilligung (Kategorie: analyse) */
351  function loadClarity() {
352    if (servicesLoaded.clarity) return;
353    servicesLoaded.clarity = true;
354    (function(c,l,a,r,i,t,y){
355      c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
356      t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
357      y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
358    })(window, document, "clarity", "script", CONFIG.CLARITY_ID);
359  }
360
361  /** Dienste basierend auf Consent laden */
362  function applyConsent(categories) {
363    if (categories.analyse) { loadGA4(); loadClarity(); }
364    if (categories.marketing) { loadMetaPixel(); loadOAIPixel(); }
365    // Google-Ads-Signale nur, wenn der Google-Tag geladen ist (analyse) UND Marketing erlaubt.
366    // Kein Tag-Load über Marketing allein (Kategorie-Beschreibung deckt Google sonst nicht).
367    if (categories.analyse && categories.marketing) grantAdsConsent();
368    if (categories.karten) activateMaps();
369    // First-Touch-Attribution (an Marketing-Consent gekoppelt) erfassen + Formularfelder befuellen
370    persistFirstTouch(categories);
371    fillAttributionFields();
372  }
373
374  // =============================================
375  // Banner-HTML erzeugen
376  // =============================================
377
378  function createBanner() {
379    // Overlay
380    var overlay = document.createElement('div');
381    overlay.className = 'sd-cc-overlay';
382    overlay.id = 'sd-cc-overlay';
383
384    // Banner
385    var banner = document.createElement('div');
386    banner.className = 'sd-cc-banner';
387    banner.id = 'sd-cc-banner';
388    banner.setAttribute('role', 'dialog');
389    banner.setAttribute('aria-label', 'Cookie-Einstellungen');
390
391    banner.innerHTML =
392      '<div class="sd-cc-inner">' +
393        '<div class="sd-cc-title">Wir respektieren deine Privatsph\u00e4re</div>' +
394        '<div class="sd-cc-text">' +
395          'Wir nutzen Cookies und \u00e4hnliche Technologien, um dir die bestm\u00f6gliche Erfahrung zu bieten. ' +
396          'Einige sind technisch notwendig, andere helfen uns, unser Angebot zu verbessern. ' +
397          'Du kannst deine Auswahl jederzeit unter \u201eCookie-Einstellungen\u201c im Footer \u00e4ndern. ' +
398          'Mehr dazu in unserer <a href="/datenschutz/">Datenschutzerkl\u00e4rung</a>.' +
399          '<br><small style="color:#8A8A8A">Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO, \u00a7 25 Abs. 1 TDDDG
399</small>' +
400        '</div>' +
401
402        '<!-- Layer 1: Hauptbuttons -->' +
403        '<div class="sd-cc-buttons">' +
404          '<button class="sd-cc-btn sd-cc-btn-accept" id="sd-cc-accept">Alle akzeptieren</button>' +
405          '<button class="sd-cc-btn sd-cc-btn-reject" id="sd-cc-reject">Alle ablehnen</button>' +
406          '<button class="sd-cc-btn sd-cc-btn-settings" id="sd-cc-toggle-details">Einstellungen</button>' +
407        '</div>' +
408
409        '<!-- Layer 2: Granulare Auswahl -->' +
410        '<div class="sd-cc-details" id="sd-cc-details">' +
411
412          '<!-- Notwendig -->' +
413          '<div class="sd-cc-category">' +
414            '<div class="sd-cc-cat-info">' +
415              '<div class="sd-cc-cat-label">Technisch notwendig <span class="sd-cc-badge-required">immer aktiv</span></div>' +
416              '<div class="sd-cc-cat-desc">Grundfunktionen wie Seitennavigation und Sicherheit. Keine externen Dienste.</div>' +
417            '</div>' +
418            '<label class="sd-cc-toggle">' +
419              '<input type="checkbox" checked disabled>' +
420              '<span class="sd-cc-toggle-slider"></span>' +
421            '</label>' +
422          '</div>' +
423
424          '<!-- Analyse -->' +
425          '<div class="sd-cc-category">' +
426            '<div class="sd-cc-cat-info">' +
427              '<div class="sd-cc-cat-label">Analyse</div>' +
428              '<div class="sd-cc-cat-desc">Google Analytics 4 + Microsoft Clarity \u2014 anonymisierte Nutzungsstatistiken, Heatmaps und Session-Recordings (Formularfelder maskiert) zur Verbesserung der Website.</div>' +
429            '</div>' +
430            '<label class="sd-cc-toggle">' +
431              '<input type="checkbox" id="sd-cc-cat-analyse">' +
432              '<span class="sd-cc-toggle-slider"></span>' +
433            '</label>' +
434          '</div>' +
435
436          '<!-- Marketing -->' +
437          '<div class="sd-cc-category">' +
438            '<div class="sd-cc-cat-info">' +
439              '<div class="sd-cc-cat-label">Marketing</div>' +
440              '<div class="sd-cc-cat-desc">Meta Pixel, Google Ads &amp; OpenAI Ads \u2014 erm\u00f6glichen personalisierte Werbeanzeigen auf Facebook/Instagram und die Conversion-Messung f\u00fcr Google Ads.</div>' +
441            '</div>' +
442            '<label class="sd-cc-toggle">' +
443              '<input type="checkbox" id="sd-cc-cat-marketing">' +
444              '<span class="sd-cc-toggle-slider"></span>' +
445            '</label>' +
446          '</div>' +
447
448          '<!-- Karten -->' +
449          '<div class="sd-cc-category">' +
450            '<div class="sd-cc-cat-info">' +
451              '<div class="sd-cc-cat-label">Karten</div>' +
452              '<div class="sd-cc-cat-desc">Google Maps \u2014 interaktive Kartenansicht f\u00fcr die Anfahrt zu unserer Location.</div>' +
453            '</div>' +
454            '<label class="sd-cc-toggle">' +
455              '<input type="checkbox" id="sd-cc-cat-karten">' +
456              '<span class="sd-cc-toggle-slider"></span>' +
457            '</label>' +
458          '</div>' +
459
460          '<div class="sd-cc-detail-buttons">' +
461            '<button class="sd-cc-btn sd-cc-btn-accept" id="sd-cc-save">Auswahl speichern</button>' +
462            '<button class="sd-cc-btn sd-cc-btn-accept" id="sd-cc-accept-all-detail">Alle akzeptieren</button>' +
463          '</div>' +
464        '</div>' +
465
466      '</div>';
467
468    document.body.appendChild(overlay);
469    document.body.appendChild(banner);
470
471    return { banner: banner, overlay: overlay };
472  }
473
474  // =============================================
475  // Google Maps Platzhalter erzeugen
476  // =============================================
477
478  function createMapsPlaceholders() {
479    var maps = document.querySelectorAll('[data-cc-maps]');
480    maps.forEach(function (el) {
481      // Nur wenn noch kein Platzhalter vorhanden
482      if (el.tagName.toLowerCase() === 'iframe' || el.classList.contains('sd-cc-maps-placeholder')) return;
483
484      var placeholder = document.createElement('div');
485      placeholder.className = 'sd-cc-maps-placeholder';
486      placeholder.innerHTML =
487        '<p>Zur Anzeige der Karte ist deine Einwilligung erforderlich.<br>' +
488        '<small>Beim Laden werden Daten an Google \u00fcbertragen.</small></p>' +
489        '<button class="sd-cc-maps-btn" data-cc-maps-load>' +
490          '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>' +
491          'Karte laden' +
492        '</button>';
493
494      // Attribute übertragen
495      placeholder.setAttribute('data-cc-maps', el.getAttribute('data-cc-maps'));
496      if (el.getAttribute('data-cc-width')) placeholder.setAttribute('data-cc-width', el.getAttribute('data-cc-width'));
497      if (el.getAttribute('data-cc-height')) placeholder.setAttribute('data-cc-height', el.getAttribute('data-cc-height'));
498
499      el.parentNode.replaceChild(placeholder, el);
500    });
501
502    // Klick auf "Karte laden" — nur Karten-Consent setzen
503    document.addEventListener('click', function (e) {
504      var btn = e.target.closest('[data-cc-maps-load]');
505      if (!btn) return;
506
507      var consent = getConsent();
508      var cats = consent ? consent.categories : { analyse: false, marketing: false, karten: false };
509      cats.karten = true;
510      saveConsent(cats);
511      activateMaps();
512    });
513  }
514
515  // =============================================
516  // Banner-Logik
517  // =============================================
518
519  function init() {
520    var consent = getConsent();
521
522    // Consent vorhanden — Dienste laden, kein Banner
523    if (consent) {
524      applyConsent(consent.categories);
525      createMapsPlaceholders();
526      // Noch nicht freigegebene Maps bleiben als Platzhalter
527      if (!consent.categories.karten) {
528        // Platzhalter schon erstellt
529      }
530      return;
531    }
532
533    // Kein Consent — Banner anzeigen
534    createMapsPlaceholders();
535    var elements = createBanner();
536    var banner = elements.banner;
537    var overlay = elements.overlay;
538
539    // Banner sichtbar machen (nach kurzer Verzögerung für Animation)
540    requestAnimationFrame(function () {
541      requestAnimationFrame(function () {
542        banner.classList.add('visible');
543        overlay.classList.add('active');
544      });
545    });
546
547    // --- Event-Handler ---
548
549    var detailsEl = document.getElementById('sd-cc-details');
550    var catAnalyse = document.getElementById('sd-cc-cat-analyse');
551    var catMarketing = document.getElementById('sd-cc-cat-marketing');
552    var catKarten = document.getElementById('sd-cc-cat-karten');
553
554    /** Banner ausblenden */
555    function hideBanner() {
556      banner.classList.remove('visible');
557      overlay.classList.remove('active');
558      setTimeout(function () {
559        banner.remove();
560        overlay.remove();
561      }, 400);
562    }
563
564    /** Kategorien aus Toggles lesen */
565    function getSelectedCategories() {
566      return {
567        analyse: catAnalyse.checked,
568        marketing: catMarketing.checked,
569        karten: catKarten.checked
570      };
571    }
572
573    // Alle akzeptieren (Layer 1)
574    document.getElementById('sd-cc-accept').addEventListener('click', function () {
575      var cats = { analyse: true, marketing: true, karten: true };
576      saveConsent(cats);
577      applyConsent(cats);
578      hideBanner();
579    });
580
581    // Alle ablehnen (Layer 1)
582    document.getElementById('sd-cc-reject').addEventListener('click', function () {
583      var cats = { analyse: false, marketing: false, karten: false };
584      saveConsent(cats);
585      hideBanner();
586    });
587
588    // Einstellungen aufklappen (Layer 2)
589    document.getElementById('sd-cc-toggle-details').addEventListener('click', function () {
590      detailsEl.classList.toggle('open');
591      this.textContent = detailsEl.classList.contains('open') ? 'Schlie\u00dfen' : 'Einstellungen';
592    });
593
594    // Auswahl speichern (Layer 2)
595    document.getElementById('sd-cc-save').addEventListener('click', function () {
596      var cats = getSelectedCategories();
597      saveConsent(cats);
598      applyConsent(cats);
599      hideBanner();
600    });
601
602    // Alle akzeptieren (Layer 2)
603    document.getElementById('sd-cc-accept-all-detail').addEventListener('click', function () {
604      var cats = { analyse: true, marketing: true, karten: true };
605      saveConsent(cats);
606      applyConsent(cats);
607      hideBanner();
608    });
609  }
610
611  // =============================================
612  // Footer-Link: Cookie-Einstellungen erneut öffnen
613  // =============================================
614
615  function bindFooterLink() {
616    document.addEventListener('click', function (e) {
617      var link = e.target.closest('[data-cc-settings]');
618      if (!link) return;
619      e.preventDefault();
620      showSettingsDialog();
621    });
622  }
623
624  /** Einstellungs-Dialog erneut anzeigen (für Widerruf) */
625  function showSettingsDialog() {
626    // Alten Banner entfernen falls vorhanden
627    var existing = document.getElementById('sd-cc-banner');
628    if (existing) existing.remove();
629    var existingOverlay = document.getElementById('sd-cc-overlay');
630    if (existingOverlay) existingOverlay.remove();
631
632    var consent = getConsent();
633    var elements = createBanner();
634    var banner = elements.banner;
635    var overlay = elements.overlay;
636
637    // Details direkt aufklappen
638    var detailsEl = document.getElementById('sd-cc-details');
639    detailsEl.classList.add('open');
640
641    // Toggle-Button-Text anpassen
642    var toggleBtn = document.getElementById('sd-cc-toggle-details');
643    toggleBtn.textContent = 'Schlie\u00dfen';
644
645    // Bestehende Einstellungen wiederherstellen
646    if (consent) {
647      var catAnalyse = document.getElementById('sd-cc-cat-analyse');
648      var catMarketing = document.getElementById('sd-cc-cat-marketing');
649      var catKarten = document.getElementById('sd-cc-cat-karten');
650      catAnalyse.checked = consent.categories.analyse;
651      catMarketing.checked = consent.categories.marketing;
652      catKarten.checked = consent.categories.karten;
653    }
654
655    // Banner sichtbar machen
656    requestAnimationFrame(function () {
657      requestAnimationFrame(function () {
658        banner.classList.add('visible');
659        overlay.classList.add('active');
660      });
661    });
662
663    /** Banner ausblenden */
664    function hideBanner() {
665      banner.classList.remove('visible');
666      overlay.classList.remove('active');
667      setTimeout(function () {
668        banner.remove();
669        overlay.remove();
670      }, 400);
671    }
672
673    /** Kategorien aus Toggles lesen */
674    function getSelectedCategories() {
675      return {
676        analyse: document.getElementById('sd-cc-cat-analyse').checked,
677        marketing: document.getElementById('sd-cc-cat-marketing').checked,
678        karten: document.getElementById('sd-cc-cat-karten').checked
679      };
680    }
681
682    // Event-Handler (neue Instanzen, da neues DOM)
683    document.getElementById('sd-cc-accept').addEventListener('click', function () {
684      var cats = { analyse: true, marketing: true, karten: true };
685      saveConsent(cats);
686      applyConsent(cats);
687      hideBanner();
688    });
689
690    document.getElementById('sd-cc-reject').addEventListener('click', function () {
691      var cats = { analyse: false, marketing: false, karten: false };
692      saveConsent(cats);
693      hideBanner();
694      // Widerruf-Calls vor dem Reload senden
695      if (servicesLoaded.analyse) revokeGA4();
696      if (servicesLoaded.marketing) revokeMetaPixel();
697      if (servicesLoaded.oai) revokeOAIPixel();
698      // Seite neu laden um geladene Scripts vollständig zu entfernen
699      if (servicesLoaded.analyse || servicesLoaded.marketing) {
700        location.reload();
701      }
702    });
703
704    document.getElementById('sd-cc-toggle-details').addEventListener('click', function () {
705      detailsEl.classList.toggle('open');
706      this.textContent = detailsEl.classList.contains('open') ? 'Schlie\u00dfen' : 'Einstellungen';
707    });
708
709    document.getElementById('sd-cc-save').addEventListener('click', function () {
710      var cats = getSelectedCategories();
711      saveConsent(cats);
712      applyConsent(cats);
713      hideBanner();
714      // Widerruf-Calls für deaktivierte Kategorien
715      if (servicesLoaded.analyse && !cats.analyse) revokeGA4();
716      if (servicesLoaded.marketing && !cats.marketing) revokeMetaPixel();
717      if (servicesLoaded.oai && !cats.marketing) revokeOAIPixel();
718      // Seite neu laden wenn Services widerrufen wurden
719      if ((servicesLoaded.analyse && !cats.analyse) || (servicesLoaded.marketing && !cats.marketing)) {
720        location.reload();
721      }
722    });
723
724    document.getElementById('sd-cc-accept-all-detail').addEventListener('click', function () {
725      var cats = { analyse: true, marketing: true, karten: true };
726      saveConsent(cats);
727      applyConsent(cats);
728      hideBanner();
729    });
730  }
731
732  // =============================================
733  // Event-Tracking (GA4 + Meta Pixel)
734  // Alle Listener laufen per Event-Delegation auf
735  // document — kein Fehler wenn Selector fehlt.
736  // =============================================
737
738  // Merker: ob Tracking-Listener aktiv sind
739  var trackingActive = false;
740
741  // Referenz auf Handler-Funktion für removeEventListener
742  var _trackingClickHandler = null;
743  var _trackingSubmitHandler = null;
744
745  /**
746   * Hilfsfunktion: GA4 + Meta Pixel Event feuern
747   * Prüft ob die jeweiligen Dienste geladen sind
748   */
749  function trackEvent(ga4Name, ga4Params, metaName, metaParams) {
750    if (window.gtag && servicesLoaded.analyse) {
751      gtag('event', ga4Name, ga4Params);
752    }
753    if (window.fbq && servicesLoaded.marketing) {
754      if (metaParams) {
755        fbq('track', metaName, metaParams);
756      } else {
757        fbq('track', metaName);
758      }
759    }
760  }
761
762  /**
763   * Prüft ob ein Element ein CTA-Button/Link für Event-Anfragen ist.
764   * Texte passend zu Sander Dickkopp: Anfragen, Buchen, Event, Veranstaltung
765   */
766  function isEventInquiryEl(el) {
767    var text = (el.textContent || el.innerText || '').trim().toLowerCase();
768    var keywords = ['anfragen', 'anfrage', 'buchen', 'event', 'veranstaltung', 'jetzt anfragen'];
769    for (var i = 0; i < keywords.length; i++) {
770      if (text.indexOf(keywords[i]) !== -1) return true;
771    }
772    // Auch href auf /kontakt/ zählt als Anfrage-CTA
773    var href = el.getAttribute('href') || '';
774    if (href.indexOf('/kontakt') !== -1 || href.indexOf('kontakt') !== -1) return true;
775    return false;
776  }
777
778  /**
779   * Prüft ob ein Element ein Speisekarten/Menü-Link ist
780   */
781  function isMenuEl(el) {
782    var text = (el.textContent || el.innerText || '').trim().toLowerCase();
783    var keywords = ['speisekarte', 'men\u00fc', 'karte'];
784    for (var i = 0; i < keywords.length; i++) {
785      if (text.indexOf(keywords[i]) !== -1) return true;
786    }
787    var href = el.getAttribute('href') || '';
788    if (href.indexOf('.pdf') !== -1) return true;
789    return false;
790  }
791
792  /**
793   * Prüft ob ein Element ein Anfahrts-Link ist
794   */
795  function isDirectionsEl(el) {
796    var text = (el.textContent || el.innerText || '').trim().toLowerCase();
797    if (text.indexOf('anfahrt') !== -1) return true;
798    var href = el.getAttribute('href') || '';
799    if (href.indexOf('maps.google') !== -1 || href.indexOf('goo.gl/maps') !== -1 || href.indexOf('google.com/maps') !== -1) return true;
800    return false;
801  }
802
803  /** Click-Delegation: alle CTA-Klicks abfangen */
804  function onTrackingClick(e) {
805    // Nächstes klickbares Element finden (auch wenn Kind-Element geklickt)
806    var el = e.target.closest('a, button');
807    if (!el) return;
808
809    var href = el.getAttribute('href') || '';
810
811    // 1. Telefon-Klick
812    if (href.indexOf('tel:') === 0) {
813      trackEvent(
814        'phone_click', { event_category: 'CTA', event_label: href },
815        'Contact'
816      );
817      return;
818    }
819
820    // 2. E-Mail-Klick
821    if (href.indexOf('mailto:') === 0) {
822      trackEvent(
823        'email_click', { event_category: 'CTA', event_label: href },
824        'Contact'
825      );
826      return;
827    }
828
829    // 3. Anfahrt / Google Maps
830    if (isDirectionsEl(el)) {
831      trackEvent(
832        'directions_click', { event_category: 'CTA', event_label: 'Anfahrt' },
833        'FindLocation'
834      );
835      return;
836    }
837
838    // 4. Speisekarte / Menü
839    if (isMenuEl(el)) {
840      trackEvent(
841        'menu_view', { event_category: 'Engagement', event_label: 'Speisekarte' },
842        'ViewContent', { content_name: 'Speisekarte' }
843      );
844      return;
845    }
846
847    // 5. Event-Anfrage / Buchen CTA
848    if (isEventInquiryEl(el)) {
849      trackEvent(
850        'event_inquiry_click', { event_category: 'CTA', event_label: 'Event Anfrage' },
851        'InitiateCheckout'
852      );
853    }
854  }
855
856  /** Submit-Delegation: Formular-Absenden abfangen */
857  function onTrackingSubmit(e) {
858    var form = e.target;
859    if (form.tagName.toLowerCase() !== 'form') return;
860
861    trackEvent(
862      'form_submit', { event_category: 'Anfrage', event_label: 'Veranstaltungsanfrage' },
863      'Lead'
864    );
865  }
866
867  /** Tracking-Listener aktivieren */
868  function enableEventTracking() {
869    if (trackingActive) return;
870    trackingActive = true;
871
872    _trackingClickHandler = onTrackingClick;
873    _trackingSubmitHandler = onTrackingSubmit;
874
875    document.addEventListener('click', _trackingClickHandler, true);
876    document.addEventListener('submit', _trackingSubmitHandler, true);
877  }
878
879  /** Tracking-Listener deaktivieren (bei Widerruf) */
880  function disableEventTracking() {
881    if (!trackingActive) return;
882    trackingActive = false;
883
884    if (_trackingClickHandler) {
885      document.removeEventListener('click', _trackingClickHandler, true);
886      _trackingClickHandler = null;
887    }
888    if (_trackingSubmitHandler) {
889      document.removeEventListener('submit', _trackingSubmitHandler, true);
890      _trackingSubmitHandler = null;
891    }
892  }
893
894  // =============================================
895  // applyConsent erweitern: Tracking-Listener
896  // miteinschalten wenn analyse ODER marketing aktiv
897  // =============================================
898  var _origApplyConsent = applyConsent;
899  applyConsent = function (categories) {
900    _origApplyConsent(categories);
901    // Listener einschalten sobald mindestens ein Tracking-Dienst aktiv
902    if (categories.analyse || categories.marketing) {
903      enableEventTracking();
904    } else {
905      disableEventTracking();
906    }
907  };
908
909  // =============================================
910  // Erweiterte Engagement-Tracking-Events
911  // Scroll-Tiefe, Zeit auf Seite, Galerie,
912  // Preisbereich, Besichtigung, FAQ
913  // =============================================
914
915  var engagementActive = false;
916  var _engagementCleanup = []; // Cleanup-Funktionen für removeEventListener
917
918  /** Scroll-Tiefe tracken: 50% und 90% */
919  function initScrollTracking() {
920    var fired = { 50: false, 90: false };
921
922    function onScroll() {
923      var scrolled = window.scrollY + window.innerHeight;
924      var total = document.documentElement.scrollHeight;
925      var pct = Math.round((scrolled / total) * 100);
926
927      if (!fired[50] && pct >= 50) {
928        fired[50] = true;
929        trackEvent('scroll_depth', { event_category: 'Engagement', event_label: '50%', value: 50 }, 'ViewContent', { content_name: 'Scroll 50%' });
930      }
931      if (!fired[90] && pct >= 90) {
932        fired[90] = true;
933        trackEvent('scroll_depth', { event_category: 'Engagement', event_label: '90%', value: 90 }, 'ViewContent', { content_name: 'Scroll 90%' });
934      }
935    }
936
937    window.addEventListener('scroll', onScroll, { passive: true });
938    _engagementCleanup.push(function () { window.removeEventListener('scroll', onScroll); });
939  }
940
941  /** Zeit auf Seite: nach 30s, 60s, 120s */
942  function initTimeTracking() {
943    var milestones = [30, 60, 120];
944    var timers = [];
945
946    milestones.forEach(function (sec) {
947      var t = setTimeout(function () {
948        trackEvent('time_on_page', { event_category: 'Engagement', event_label: sec + 's', value: sec }, 'ViewContent', { content_name: 'Zeit ' + sec + 's' });
949      }, sec * 1000);
950      timers.push(t);
951    });
952
953    _engagementCleanup.push(function () {
954      timers.forEach(function (t) { clearTimeout(t); });
955    });
956  }
957
958  /** Galerie-Lightbox geöffnet */
959  function initGalleryTracking() {
960    function onGalleryClick(e) {
961      // Lightbox-Trigger: .gallery-slide oder data-lightbox
962      var slide = e.target.closest('.gallery-slide, [data-lightbox]');
963      if (!slide) return;
964      trackEvent('gallery_open', { event_category: 'Engagement', event_label: 'Galerie' }
964, 'ViewContent', { content_name: 'Galerie' });
965    }
966    document.addEventListener('click', onGalleryClick, true);
967    _engagementCleanup.push(function () { document.removeEventListener('click', onGalleryClick, true); });
968  }
969
970  /** Preisbereich ins Viewport gescrollt (einmalig) */
971  function initPricingTracking() {
972    // Preisbereich: Element mit class pricing-highlight oder text "€"
973    var pricingEl = document.querySelector('.pricing-highlight, [data-pricing], #preise, #pricing');
974    if (!pricingEl) return;
975
976    var fired = false;
977    var observer = new IntersectionObserver(function (entries) {
978      entries.forEach(function (entry) {
979        if (!fired && entry.isIntersecting) {
980          fired = true;
981          trackEvent('pricing_view', { event_category: 'Engagement', event_label: 'Preise gesehen' }, 'ViewContent', { content_name: 'Preise' });
982          observer.disconnect();
983        }
984      });
985    }, { threshold: 0.3 });
986
987    observer.observe(pricingEl);
988    _engagementCleanup.push(function () { observer.disconnect(); });
989  }
990
991  /** Besichtigungs-Popup geöffnet + Formular abgesendet */
992  function initBesichtigungTracking() {
993    // Popup öffnen — FloatingButton + alle Trigger
994    function onBesichtigungOpen(e) {
995      var trigger = e.target.closest('#floatingBesichtigung, [data-besichtigung-open]');
996      if (!trigger) return;
997      trackEvent('besichtigung_open', { event_category: 'Besichtigung', event_label: 'Popup geöffnet' }, 'InitiateCheckout');
998    }
999    document.addEventListener('click', onBesichtigungOpen, true);
1000    _engagementCleanup.push(function () { document.removeEventListener('click', onBesichtigungOpen, true); });
1001
1002    // Formular abgesendet
1003    function onBesichtigungSubmit(e) {
1004      var form = e.target.closest('#besichtigungForm');
1005      if (!form) return;
1006      trackEvent('besichtigung_submit', { event_category: 'Besichtigung', event_label: 'Besichtigungsanfrage' }, 'Lead');
1007    }
1008    document.addEventListener('submit', onBesichtigungSubmit, true);
1009    _engagementCleanup.push(function () { document.removeEventListener('submit', onBesichtigungSubmit, true); });
1010  }
1011
1012  /** FAQ-Fragen geöffnet */
1013  function initFaqTracking() {
1014    function onFaqClick(e) {
1015      var item = e.target.closest('.faq-item');
1016      if (!item) return;
1017      // Nur tracken wenn die Frage jetzt geöffnet wird (nicht beim Schließen)
1018      var answer = item.querySelector('.faq-answer');
1019      if (!answer) return;
1020      // Nach dem Klick prüfen (kurze Verzögerung damit DOM-State aktuell)
1021      setTimeout(function () {
1022        if (answer.classList.contains('open')) {
1023          var label = (item.querySelector('h3, [data-faq-label], button') || item).textContent.trim().slice(0, 60);
1024          trackEvent('faq_open', { event_category: 'Engagement', event_label: label }, 'ViewContent', { content_name: 'FAQ: ' + label });
1025        }
1026      }, 50);
1027    }
1028    document.addEventListener('click', onFaqClick, true);
1029    _engagementCleanup.push(function () { document.removeEventListener('click', onFaqClick, true); });
1030  }
1031
1032  /** Alle Engagement-Tracker starten */
1033  function enableEngagementTracking() {
1034    if (engagementActive) return;
1035    engagementActive = true;
1036
1037    initScrollTracking();
1038    initTimeTracking();
1039    initGalleryTracking();
1040    initPricingTracking();
1041    initBesichtigungTracking();
1042    initFaqTracking();
1043  }
1044
1045  /** Alle Engagement-Tracker stoppen */
1046  function disableEngagementTracking() {
1047    if (!engagementActive) return;
1048    engagementActive = false;
1049
1050    _engagementCleanup.forEach(function (fn) { fn(); });
1051    _engagementCleanup = [];
1052  }
1053
1054  // applyConsent nochmals erweitern: Engagement-Tracking
1055  var _origApplyConsent2 = applyConsent;
1056  applyConsent = function (categories) {
1057    _origApplyConsent2(categories);
1058    if (categories.analyse || categories.marketing) {
1059      enableEngagementTracking();
1060    } else {
1061      disableEngagementTracking();
1062    }
1063  };
1064
1065  // =============================================
1066  // Start
1067  // =============================================
1068
1069  // Warte auf DOM
1070  if (document.readyState === 'loading') {
1071    document.addEventListener('DOMContentLoaded', function () {
1072      init();
1073      bindFooterLink();
1074    });
1075  } else {
1076    init();
1077    bindFooterLink();
1078  }
1079
1080  // First-Touch-Felder unmittelbar vor jedem Formular-Submit nachfuellen (Absicherung,
1081  // falls applyConsent das Formular noch nicht erreicht hatte). No-op ohne Consent/Storage.
1082  document.addEventListener('submit', function () { fillAttributionFields(); }, true);
1083
1084  // Globale API für programmatischen Zugriff
1085  window.SDCookieConsent = {
1086    getConsent: getConsent,
1087    showSettings: showSettingsDialog,
1088    getAttribution: getStoredAttribution,
1089    isAccepted: function (category) {
1090      var consent = getConsent();
1091      return consent ? !!consent.categories[category] : false;
1092    }
1093  };
1094
1095})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.