// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites. // Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen; // nach Ablehnen oder bei GPC/DNT geht nichts mehr raus. import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, bannerFor, CONSENT_KEY, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js'; function main() { const DOC = document; const WIN = window; if (WIN.__wkAnalytics) return; // doppelt eingebunden WIN.__wkAnalytics = true; const SELF = DOC.currentScript && DOC.currentScript.getAttribute && DOC.currentScript.getAttribute('data-site') ? DOC.currentScript : DOC.querySelector('script[src*="/a/w.js"]'); if (!SELF) return; const site = String(SELF.getAttribute('data-site') || ''); if (!/^[a-z0-9]{16,40}$/.test(site)) return; const colorAttr = String(SELF.getAttribute('data-color') || ''); const accent = contrastOk(colorAttr) ? colorAttr : '#111111'; // data-variant="eigen": Banner-Fassung fuer WEBklars eigene Websites (Sie-Form, ohne Auftragsverarbeiter) const banner = bannerFor(String(SELF.getAttribute('data-variant') || '')); const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, ''); const base = src.replace(/[^/]*$/, ''); const endpoint = base + 'e'; // ---- Heatmap-Modus: nichts senden, kein Banner ---- const qs = new URLSearchParams(WIN.location.search); const heatToken = qs.get('__wk_heat'); if (heatToken) { // __wk_embed=1: Ansicht im Geraeterahmen des Kundenportals (h.js ohne Leiste, Links gesperrt) const embed = qs.get('__wk_embed') === '1'; qs.delete('__wk_heat'); qs.delete('__wk_embed'); const rest = qs.toString(); try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ } const s = DOC.createElement('script'); s.setAttribute('src', base + 'h.js'); s.setAttribute('data-token', heatToken); if (embed) s.setAttribute('data-embed', '1'); (DOC.head || DOC.documentElement).appendChild(s); return; } // ---- Senden: Fehler werden verschluckt ---- function send(obj) { try { const body = JSON.stringify(obj); fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {}); } catch (e) { /* egal */ } } const safe = (fn) => function () { try { return fn.apply(this, arguments); } catch (e) { /* nie die Seite stoeren */ } }; const vp = () => vpClass(WIN.innerWidth); const ownHost = () => WIN.location.hostname; // ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ---- let storage = null; try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ } const dnt = (v) => v === '1' || v === 'yes'; const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks const stored = readConsent(storage, Date.now(), banner.version); let memChoice = null; let tracking = false; // Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt) function choiceNow() { const c = readConsent(storage, Date.now(), banner.version); return c ? c.a : (memChoice ? memChoice.a : null); } const counting = () => choiceNow() !== 'reject'; // ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ---- let lastPath = null; let pvStart = Date.now(); let maxScroll = 0; let enSent = false; let pvSent = false; let cxSent = false; function sendPv(first) { if (!counting()) return; lastPath = cleanPath(WIN.location.pathname); pvStart = Date.now(); maxScroll = 0; enSent = false; const ev = { t: 'pv', site, path: lastPath }; if (choiceNow() === 'accept') { if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; } ev.vp = vp(); } pvSent = true; send(ev); } let navTimer = null; function scheduleNav() { if (navTimer || !counting()) return; navTimer = setTimeout(safe(function () { navTimer = null; if (!counting() || cleanPath(WIN.location.pathname) === lastPath) return; // SPA-Seitenwechsel (Router mit preventDefault, onLinkClick greift dann nicht): en der vorigen Seite zuerst sendEn(); sendPv(false); }), 300); } let hooksOn = false; function ensureHooks() { if (hooksOn) return; hooksOn = true; WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); })); try { const orig = WIN.history.pushState; WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; }; WIN.addEventListener('popstate', safe(scheduleNav)); } catch (e) { /* egal */ } } // Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden function onAccept() { ensureHooks(); if (!pvSent) { sendPv(true); return; } if (cxSent) return; cxSent = true; send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() }); } if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); } // ---- Tracking (nur nach Annehmen erreichbar) ---- const hosts = []; const scrollPct = () => { const h = DOC.documentElement.scrollHeight || 1; return Math.max(0, Math.min(100, Math.round(((WIN.scrollY || 0) + WIN.innerHeight) / h * 100))); }; const rel = (v, total) => Math.max(0, Math.min(1, Math.round(v / (total || 1) * 10000) / 10000)); const onClick = safe(function (e) { if (!allowed()) return; const t = e.target; if (!t || hosts.indexOf(t) !== -1) return; const dw = DOC.documentElement.scrollWidth, dh = DOC.documentElement.scrollHeight; const px = typeof e.pageX === 'number' ? e.pageX : (e.clientX || 0) + (WIN.scrollX || 0); const py = typeof e.pageY === 'number' ? e.pageY : (e.clientY || 0) + (WIN.scrollY || 0); const path = cleanPath(WIN.location.pathname); send({ t: 'ck', site, path, vp: vp(), x: rel(px, dw), y: rel(py, dh), sel: cssPath(t) }); let a = t; while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode; const kind = a ? contactKind(a, ownHost()) : null; if (kind) { const ev = { t: 'ct', site, path, kind }; if (kind === 'out') ev.host = linkHost(a); send(ev); } }); const onScroll = safe(function () { maxScroll = Math.max(maxScroll, scrollPct()); }); function sendEn() { if (enSent || !allowed()) return; enSent = true; send({ t: 'en', site, path: lastPath || cleanPath(WIN.location.pathname), vp: vp(), sec: Math.max(0, Math.min(3600, Math.round((Date.now() - pvStart) / 1000))), scroll: Math.max(maxScroll, scrollPct()) }); } const onLeave = safe(sendEn); // Interner Seitenwechsel per Link: en sofort senden, der Beacon beim pagehide geht in Chromium oft verloren. // Bubbling-Listener am Dokument: laeuft nach den Handlern der Seite, defaultPrevented ist dann bekannt. const onLinkClick = safe(function (e) { if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || (typeof e.button === 'number' && e.button !== 0)) return; let a = e.target; while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode; if (!a || hosts.indexOf(e.target) !== -1) return; const tg = String(a.getAttribute('target') || '').toLowerCase(); if ((tg && tg !== '_self') || a.getAttribute('download') !== null) return; const u = new URL(String(a.href || a.getAttribute('href') || ''), WIN.location.href); if (u.protocol !== 'http:' && u.protocol !== 'https:') return; if (u.origin !== WIN.location.origin || u.pathname === WIN.location.pathname) return; sendEn(); }); const onVis = safe(function () { if (DOC.visibilityState === 'hidden') onLeave(); }); // Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen) function allowed() { const ok = choiceNow() === 'accept'; if (!ok && tracking) stopTracking(); return ok; } function startTracking() { if (tracking) return; tracking = true; DOC.addEventListener('click', onClick, { capture: true, passive: true }); DOC.addEventListener('click', onLinkClick, { passive: true }); DOC.addEventListener('visibilitychange', onVis); WIN.addEventListener('pagehide', onLeave); WIN.addEventListener('scroll', onScroll, { passive: true }); } function stopTracking() { tracking = false; DOC.removeEventListener('click', onClick, { capture: true }); DOC.removeEventListener('click', onLinkClick); DOC.removeEventListener('visibilitychange', onVis); WIN.removeEventListener('pagehide', onLeave); WIN.removeEventListener('scroll', onScroll); } // ---- Einwilligung ---- let bannerHost = null; let linkHostEl = null; function choose(a) { memChoice = null; try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: banner.version })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } send({ t: 'bn', site, a }); if (a === 'accept') { startTracking(); onAccept(); } else stopTracking(); closeBanner(); } function el(tag, attrs, text) { const n = DOC.createElement(tag); for (const k in attrs || {}) n.setAttribute(k, attrs[k]); if (text != null) n.textContent = text; return n; } const CSS = ':host{all:initial}' + '*{box-sizing:border-box}' + '.bar{display:flex;align-items:center;justify-content:center;gap:16px;padding:14px 20px;background:#fff;color:#111;border-top:1px solid #d0d0d0;box-shadow:0 -2px 12px rgba(0,0,0,.12);font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}' + '.t{margin:0;max-width:720px}' + '.t a{color:#111;text-decoration:underline}' + '.r{display:flex;gap:10px;flex:none}' + 'button{font:600 15px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;width:150px;height:44px;padding:0 20px;border:2px solid ' + accent + ';border-radius:8px;cursor:pointer;background:' + accent + ';color:#fff}' + 'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' + '@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}'; let prevPad = ''; function applyPad() { if (!bannerHost) return; const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0); DOC.body.style.paddingBottom = h + 'px'; } const onResize = safe(applyPad); function openBanner(focus) { if (bannerHost) return; const prev = DOC.activeElement; bannerHost = DOC.createElement('div'); const st = bannerHost.style; st.position = 'fixed'; st.left = '0'; st.right = '0'; st.bottom = '0'; st.zIndex = '2147483647'; hosts.push(bannerHost); const root = bannerHost.attachShadow({ mode: 'open' }); root.appendChild(el('style', {}, CSS)); const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA }); const p = el('p', { class: 't' }, banner.text); p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL)); const row = el('div', { class: 'r' }); const no = el('button', { type: 'button' }, 'Ablehnen'); const yes = el('button', { type: 'button' }, 'Annehmen'); no.addEventListener('click', safe(function () { choose('reject'); if (prev && prev.focus) prev.focus(); })); yes.addEventListener('click', safe(function () { choose('accept'); if (prev && prev.focus) prev.focus(); })); row.appendChild(no); row.appendChild(yes); bar.appendChild(p); bar.appendChild(row); root.appendChild(bar); DOC.body.appendChild(bannerHost); // Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben prevPad = DOC.body.style.paddingBottom; applyPad(); WIN.addEventListener('resize', onResize); if (linkHostEl) linkHostEl.style.display = 'none'; if (focus === true) no.focus(); } function closeBanner() { if (!bannerHost) return; const i = hosts.indexOf(bannerHost); if (i !== -1) hosts.splice(i, 1); bannerHost.remove(); bannerHost = null; WIN.removeEventListener('resize', onResize); DOC.body.style.paddingBottom = prevPad; if (linkHostEl) linkHostEl.style.display = ''; } // Einstellungsknopf: angeknabberter Keks unten rechts, dreht sich langsam (nicht bei reduzierter Bewegung). // Beim Darueberfahren haelt er an und zeigt "Datenschutz-Einstellungen"; das ist auch sein Name fuer Screenreader. // Inline-SVG statt data:-Grafik, damit eine strenge CSP der Website (img-src) ihn nicht blockiert. const SETTINGS_CSS = ':host{all:initial}' + '.b{position:relative;display:block;width:44px;height:44px;padding:2px;margin:0;border:0;border-radius:50%;background:transparent;cursor:pointer;-webkit-tap-highlight-color:transparent}' + '.b:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' + '.c{display:block;width:40px;height:40px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.35));animation:wk-spin 9s linear infinite}' + '.b:hover .c,.b:focus-visible .c{animation-play-state:paused}' + '.l{position:absolute;right:52px;top:50%;transform:translate(6px,-50%);padding:7px 12px;border-radius:999px;background:#fff;color:#222;box-shadow:0 2px 10px rgba(0,0,0,.18);font:600 13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}' + '.b:hover .l,.b:focus-visible .l{opacity:1;transform:translate(0,-50%)}' + '@keyframes wk-spin{to{transform:rotate(360deg)}}' + '@media (prefers-reduced-motion:reduce){.c{animation:none}.l{transition:none}}'; const SVG_NS = 'http://www.w3.org/2000/svg'; function svgEl(tag, attrs) { const n = DOC.createElementNS(SVG_NS, tag); for (const k in attrs) n.setAttribute(k, attrs[k]); return n; } function cookieSvg() { const svg = svgEl('svg', { class: 'c', viewBox: '0 0 64 64', 'aria-hidden': 'true', focusable: 'false' }); // Umriss mit Bissspur oben rechts (zwei Bissboegen, dann der grosse Kreis zurueck) svg.appendChild(svgEl('path', { d: 'M44.84 6A10 10 0 0 0 57.5 19.37A7.5 7.5 0 0 0 60.96 33.5A29 29 0 1 1 44.84 6Z', fill: '#dfa25b', stroke: '#a5652c', 'stroke-width': '3', 'stroke-linejoin': 'round' })); const chips = [[19, 22, 4.2, 3.3, -20], [31, 14, 3.2, 2.6, 15], [33, 33, 4, 3.2, 30], [46, 41, 3.6, 2.8, -35], [21, 45, 4, 3, 10], [36, 52, 3, 2.4, -10], [11, 34, 2.6, 2.1, 40], [47, 31, 2.4, 2, 0]]; for (const c of chips) svg.appendChild(svgEl('ellipse', { cx: c[0], cy: c[1], rx: c[2], ry: c[3], transform: 'rotate(' + c[4] + ' ' + c[0] + ' ' + c[1] + ')', fill: '#5a3216' })); const crumbs = [[26, 28, 1.1], [40, 22, 1], [28, 40, 1.2], [14, 26, 0.9], [40, 46, 1]]; for (const c of crumbs) svg.appendChild(svgEl('circle', { cx: c[0], cy: c[1], r: c[2], fill: '#f3c98c' })); return svg; } function addSettingsLink() { linkHostEl = DOC.createElement('div'); const st = linkHostEl.style; st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646'; hosts.push(linkHostEl); const root = linkHostEl.attachShadow({ mode: 'open' }); root.appendChild(el('style', {}, SETTINGS_CSS)); const b = el('button', { type: 'button', class: 'b', 'aria-label': SETTINGS_LABEL }); b.appendChild(el('span', { class: 'l', 'aria-hidden': 'true' }, SETTINGS_LABEL)); b.appendChild(cookieSvg()); b.addEventListener('click', safe(function () { openBanner(true); })); root.appendChild(b); DOC.body.appendChild(linkHostEl); } DOC.addEventListener('click', safe(function (e) { const t = e.target; if (t && t.closest && t.closest('[data-webklar-consent]')) { if (e.preventDefault) e.preventDefault(); openBanner(true); } })); if (!DOC.querySelector('[data-webklar-consent]')) addSettingsLink(); // Einwilligung in anderem Tab geaendert WIN.addEventListener('storage', safe(function (e) { if (e.key !== null && e.key !== CONSENT_KEY) return; const c = readConsent(storage, Date.now(), banner.version); if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (c) closeBanner(); })); if (stored) { if (stored.a === 'accept') startTracking(); } else { openBanner(); send({ t: 'bn', site, a: 'shown' }); } } try { main(); } catch (e) { /* nie die Seite stoeren */ }