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 & 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.