/* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen). initAnalyse(rootEl, { projectId }): je nach Zustand der Analyse Einschalten (off), Hinweis (disabled/blocked) oder Auswertung (active). Reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: funktionen.css. Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ import { esc, safeUrl, request } from './fn-util.js' import { analyseOf, pickAnalyseProject, analyseSel, bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from './analyse-view.js' import { HEAT_DEVICES, deviceHtml, heatCaption } from './geraete.js' const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] const HEX_RE = /^#[0-9a-f]{6}$/i const nf = new Intl.NumberFormat('de-DE') const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.' export async function initAnalyse(root, { projectId } = {}) { if (!root) return if (projectId) analyseSel.projectId = projectId const { projects = [] } = await request('/api/funktionen') const usable = projects.filter((p) => !analyseOf(p).comingSoon) if (!usable.length) { root.innerHTML = '
Die Analyse ist für deine Websites noch nicht freigeschaltet.
' return } const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) analyseSel.projectId = project.projectId root.innerHTML = ` ${usable.length > 1 ? `
` : ''}
` root.querySelector('#an-project')?.addEventListener('change', (ev) => { analyseSel.projectId = ev.target.value initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) }) const stage = root.querySelector('#an-stage') const a = analyseOf(project) const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) if (a.state === 'active') renderAnalyseStats(stage, project, reload) else if (a.state === 'disabled') stage.innerHTML = '

Analyse ist abgeschaltet

Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

' else if (a.state === 'unknown') stage.innerHTML = '

Die Daten konnten gerade nicht geladen werden.

' else if (!a.eligible) stage.innerHTML = `

Analyse nicht verfügbar

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}

` else renderAnalyseActivation(stage, project, reload) } function bannerPreview(color) { const c = bannerColor(color) const btn = `style="background:${c};border-color:${c};color:#fff"` return ` ` } const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' // Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt function controllerBlock(controller) { if (controller.ok !== true) return `` return `

Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

${controllerLines(controller).map(esc).join(' · ')}

Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

` } export function renderAnalyseActivation(stage, project, reload) { const a = analyseOf(project) const legal = a.legal || {} const controller = a.controller || { ok: false } let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' stage.innerHTML = `

Analyse für ${esc(project.projectName)} einschalten

Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.

Farbe des Banners

${PRESET_COLORS.map((c) => ``).join('')}
${controllerBlock(controller)}
` const input = stage.querySelector('#an-color') const hint = stage.querySelector('#an-hint') const paint = () => { stage.querySelector('#an-preview').innerHTML = bannerPreview(color) hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) } paint() input.addEventListener('input', () => { color = input.value; paint() }) stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) const box = stage.querySelector('#an-avv') const button = stage.querySelector('#an-on') const error = stage.querySelector('#an-error') box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) button.addEventListener('click', async () => { if (!box.checked || controller.ok !== true) return button.disabled = true error.classList.add('hidden') try { await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) reload() } catch (err) { error.textContent = err.message error.classList.remove('hidden') button.disabled = !box.checked || controller.ok !== true } }) } function lineChart(series) { const m = chartModel(series) if (!m.points.length) return '

Noch keine Daten.

' const total = m.points.reduce((sum, p) => sum + p.pv, 0) const first = m.points[0].day const last = m.points[m.points.length - 1].day return ` Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))} ${m.yTicks.map((t) => `${esc(t.label)}`).join('')} ${m.xLabels.map((l) => `${esc(l.label)}`).join('')} ${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))}`).join('') : ''} ` } function barList(title, items, labelOf, { html = false } = {}) { const rows = barRows(items, labelOf) return `

${esc(title)}

${rows.length ? `` : '

Noch keine Daten.

'}
` } const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${esc(countryName(key))}` let statsRequestId = 0 async function renderAnalyseStats(stage, project, reload) { const myRequest = ++statsRequestId const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30 const pid = project.projectId stage.innerHTML = `
${[7, 30, 90].map((r) => ``).join('')}

Lade…

Länderdaten: IP-Geolocation by DB-IP

` stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) })) renderAnalyseOff(stage.querySelector('#an-off'), pid, reload) const data = stage.querySelector('#an-data') let stats try { stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) } catch (err) { if (!stage.isConnected || myRequest !== statsRequestId) return data.innerHTML = `` return } if (!stage.isConnected || myRequest !== statsRequestId) return const t = stats.totals || {} const rate = Math.round((Number(t.consentRate) || 0) * 100) const kpi = (label, value) => `
${label}${esc(value)}
` const b = stats.banner || {} data.innerHTML = `
${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}

Seitenaufrufe pro Tag

${lineChart(stats.series)}
${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')} ${barList('Herkunft', stats.refs, refLabel)} ${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })} ${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')} ${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)} ${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))}

${esc(KPI_NOTE)}

` renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) } /* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks. Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */ const heatSel = { big: 'd' } const HEAT_TIMEOUT_MS = 20000 let geraeteCss = null function loadGeraeteCss() { if (!geraeteCss) { geraeteCss = new Promise((resolve) => { const link = document.createElement('link') link.rel = 'stylesheet' link.href = '/geraete.css' link.onload = link.onerror = () => resolve() document.head.appendChild(link) }) } return geraeteCss } // Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler let heatListening = false function onHeatMessage(ev) { const d = ev.data if (!d || d.wk !== 'heat') return for (const fig of document.querySelectorAll('.hs-dev')) { const frame = fig.querySelector('iframe') if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue clearTimeout(fig.heatTimer) fig.dataset.state = d.state === 'ready' ? 'ready' : 'error' setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n))) } } function setHeatCaption(fig, text) { const cap = fig.querySelector('.hs-cap') if (cap) cap.textContent = text } // Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website function fitHeatDevice(fig) { fig.heatResize?.disconnect() const canvas = fig.querySelector('.dm-canvas') const host = canvas?.parentElement if (!host) return const fit = (width) => { if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6)) } fit(parseFloat(getComputedStyle(host).width) || host.clientWidth) if (typeof ResizeObserver === 'function') { fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width)) fig.heatResize.observe(host) } } async function loadHeatDevice(fig, projectId, vp, path) { const run = (fig.heatRun = (fig.heatRun || 0) + 1) clearTimeout(fig.heatTimer) fig.heatResize?.disconnect() fig.dataset.state = 'loading' fig.innerHTML = `
${esc(heatCaption('loading'))}
` let url try { ;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } })) } catch (err) { if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) return } await loadGeraeteCss() if (run !== fig.heatRun || !fig.isConnected) return if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error')) fig.dataset.origin = new URL(url).origin fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` }) fitHeatDevice(fig) // Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden let loads = 0 fig.querySelector('iframe').addEventListener('load', () => { if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path) }) fig.heatTimer = setTimeout(() => { if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path) }, HEAT_TIMEOUT_MS) } // Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher async function showHeatFallback(fig, projectId, vp, path) { fig.dataset.state = 'timeout' const cap = fig.querySelector('.hs-cap') if (!cap) return cap.textContent = heatCaption('timeout') try { const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } }) // Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` In neuem Tab öffnen`) } catch { /* Text reicht */ } } function renderAnalyseHeat(box, projectId, pages) { if (!box) return if (!pages.length) { box.innerHTML = '

Heatmap

Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

' return } if (!heatListening) { window.addEventListener('message', onHeatMessage) heatListening = true } const big = heatSel.big === 't' ? 't' : 'd' box.innerHTML = `

Heatmap

${['d', 't'].map((vp) => ``).join('')}

So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.

` const stage = box.querySelector('.hs-stage') const bigFig = box.querySelector('.hs-dev--big') const phoneFig = box.querySelector('.hs-dev--phone') const page = () => box.querySelector('#an-heat-page').value || '/' const show = (fig, vp) => { fig.heatReloads = 0 loadHeatDevice(fig, projectId, vp, page()) } box.querySelector('#an-heat-page').addEventListener('change', () => { show(bigFig, heatSel.big) show(phoneFig, 'm') }) box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => { if (b.dataset.vp === heatSel.big) return heatSel.big = b.dataset.vp box.querySelectorAll('.hs-switch button').forEach((x) => { const on = x.dataset.vp === heatSel.big x.classList.toggle('active', on) x.setAttribute('aria-pressed', String(on)) }) stage.dataset.big = heatSel.big show(bigFig, heatSel.big) })) heatSel.big = big // Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite) const start = () => { show(bigFig, heatSel.big) show(phoneFig, 'm') } if (typeof IntersectionObserver !== 'function') return start() const io = new IntersectionObserver((entries) => { if (!entries.some((e) => e.isIntersecting)) return io.disconnect() start() }, { rootMargin: '300px 0px' }) io.observe(stage) } // Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog export function renderAnalyseOff(box, projectId, reload) { box.innerHTML = `

Analyse ausschalten

Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

` const actions = box.querySelector('#an-off-actions') const error = box.querySelector('#an-off-error') box.querySelector('#an-off-ask').addEventListener('click', () => { actions.innerHTML = 'Wirklich ausschalten? ' actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload)) actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => { ev.currentTarget.disabled = true try { await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) box.innerHTML = `

Analyse ausgeschaltet

Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

` box.querySelector('#an-off-ok').addEventListener('click', () => reload()) } catch (err) { renderAnalyseOff(box, projectId, reload) const e2 = box.querySelector('#an-off-error') e2.textContent = err.message e2.classList.remove('hidden') } }) }) }