Analyse: Oberfläche in eigenes Modul analyse.js, gemeinsame Helfer in fn-util.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 21:34:43 +00:00
1 parent 97bff91a99
commit 9d1691cf6b
9 files changed
+426 -409

No files matched your search

+12 -1
View File
@@ -1,4 +1,4 @@
/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
// Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1
export function contrastOk(hex) {
@@ -90,3 +90,14 @@ export function controllerLines(controller) {
const c = controller || {}
return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean)
}
/* Analyse-Zustand je Website (comingSoon = Schalter aus) und Auswahl */
export const analyseOf = (project) => project.analyse || { comingSoon: true }
export const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
export function pickAnalyseProject(projects) {
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
}
// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js)
export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }
+377
View File
@@ -0,0 +1,377 @@
/* 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 = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>'
return
}
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
analyseSel.projectId = project.projectId
root.innerHTML = `
${usable.length > 1 ? `
<div class="fn-project kb-panel">
<label for="an-project">Website</label>
<select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
</div>` : ''}
<div id="an-stage"></div>`
root.querySelector('#an-project')?.addEventListener('change', (ev) => {
analyseSel.projectId = ev.target.value
initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
})
const stage = root.querySelector('#an-stage')
const a = analyseOf(project)
const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
else if (a.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</p></div>'
else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></div>`
else renderAnalyseActivation(stage, project, reload)
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
<p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p>
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
</div>`
}
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 `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
}
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 = `
<div class="kb-panel an-panel">
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p>
<p>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.</p>
<h4 class="an-h4">Farbe des Banners</h4>
<div class="an-colors">
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
</div>
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
<div id="an-preview"></div>
${controllerBlock(controller)}
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>
<p class="error hidden" id="an-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
</div>`
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 '<p class="nw-empty">Noch keine Daten.</p>'
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 `
<svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title">
<title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))}</title>
${m.yTicks.map((t) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${t.y}" y2="${t.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${t.y + 4}" text-anchor="end">${esc(t.label)}</text>`).join('')}
${m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')}
<path class="an-area" d="${m.area}"/>
<path class="an-line" d="${m.line}"/>
${m.points.length <= 31 ? m.points.map((p) => `<circle class="an-dot" cx="${p.x}" cy="${p.y}" r="3"><title>${esc(p.day)}: ${esc(nf.format(p.pv))}</title></circle>`).join('') : ''}
</svg>`
}
function barList(title, items, labelOf, { html = false } = {}) {
const rows = barRows(items, labelOf)
return `
<section class="kb-panel an-list">
<h3>${esc(title)}</h3>
${rows.length ? `<ul class="an-rows">${rows.map((r) => `
<li><div class="an-row-top"><span class="an-row-label">${html ? r.label : esc(r.label)}</span><b>${esc(nf.format(r.count))}</b></div><div class="an-bar"><i style="width:${r.pct}%"></i></div></li>`).join('')}</ul>` : '<p class="nw-empty">Noch keine Daten.</p>'}
</section>`
}
const COUNTRY_ROW = (key) => `${flagEmoji(key) ? `<span aria-hidden="true">${flagEmoji(key)}</span> ` : ''}${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 = `
<div class="an-range" role="tablist" aria-label="Zeitraum">${[7, 30, 90].map((r) => `<button type="button" role="tab" class="an-tab${r === range ? ' active' : ''}" aria-selected="${r === range}" data-range="${r}">${r} Tage</button>`).join('')}</div>
<div id="an-data"><p class="kb-empty">Lade…</p></div>
<section class="kb-panel an-heat" id="an-heat"></section>
<section class="kb-panel an-off" id="an-off"></section>
<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p>`
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 = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
return
}
if (!stage.isConnected || myRequest !== statsRequestId) return
const t = stats.totals || {}
const rate = Math.round((Number(t.consentRate) || 0) * 100)
const kpi = (label, value) => `<div class="an-kpi"><span>${label}</span><b>${esc(value)}</b></div>`
const b = stats.banner || {}
data.innerHTML = `
<div class="an-kpis">${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} %`)}</div>
<section class="kb-panel an-chart-box"><h3>Seitenaufrufe pro Tag</h3>${lineChart(stats.series)}</section>
<div class="an-grid-lists">
${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]))}
</div>
<p class="an-note">${esc(KPI_NOTE)}</p>`
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 = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
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', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
} catch {
/* Text reicht */
}
}
function renderAnalyseHeat(box, projectId, pages) {
if (!box) return
if (!pages.length) {
box.innerHTML = '<h3>Heatmap</h3><p class="nw-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
return
}
if (!heatListening) {
window.addEventListener('message', onHeatMessage)
heatListening = true
}
const big = heatSel.big === 't' ? 't' : 'd'
box.innerHTML = `
<div class="hs-head">
<h3>Heatmap</h3>
<div class="hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button" class="an-tab${vp === big ? ' active' : ''}" data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
</div>
<p>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.</p>
<div class="an-heat-row">
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
</div>
<div class="hs-stage" data-big="${big}">
<figure class="hs-dev hs-dev--big"></figure>
<figure class="hs-dev hs-dev--phone"></figure>
</div>`
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 = `
<h3>Analyse ausschalten</h3>
<p>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.</p>
<p class="error hidden" id="an-off-error" role="alert"></p>
<div class="nw-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
const actions = box.querySelector('#an-off-actions')
const error = box.querySelector('#an-off-error')
box.querySelector('#an-off-ask').addEventListener('click', () => {
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
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 = `
<h3>Analyse ausgeschaltet</h3>
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
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')
}
})
})
}
+1 -1
View File
@@ -1917,7 +1917,7 @@ const viewLoaders = { chat: initChat }
const viewRefreshers = {
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
}
const loadedViews = new Set()
+22
View File
@@ -0,0 +1,22 @@
/* Gemeinsame Hilfen der Funktions-Module (funktionen.js, analyse.js): Escaping fuer Kundentexte, URL-Pruefung, API-Aufruf. */
export const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
export const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
export async function request(path, { method = 'GET', body, raw } = {}) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
body: raw || (body ? JSON.stringify(body) : undefined),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
if (data.field) err.field = data.field
throw err
}
return data
}
+2 -400
View File
@@ -3,8 +3,8 @@
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
import { 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'
import { esc, safeUrl, request } from '/fn-util.js'
import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.js'
const PRICE = '30 €'
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
@@ -25,10 +25,6 @@ const TYPES = [
['combo', 'Foto + Text', 'beides', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'],
]
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
function fmtDay(value) {
const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return ''
@@ -41,23 +37,6 @@ function berlinIsoDay(offsetDays = 0) {
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
}
async function request(path, { method = 'GET', body, raw } = {}) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
body: raw || (body ? JSON.stringify(body) : undefined),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
if (data.field) err.field = data.field
throw err
}
return data
}
/* ------------------------------------------------------------ Zustand --- */
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
const selection = { projectId: '' }
@@ -99,15 +78,9 @@ function newsTile(projects) {
}
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
const analyseOf = (project) => project.analyse || { comingSoon: true }
const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.'
const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.'
function pickAnalyseProject(projects) {
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
}
function analyseTile(projects) {
const target = pickAnalyseProject(projects)
const a = target && analyseOf(target)
@@ -607,374 +580,3 @@ function renderEditor(stage, project) {
sync()
return loadList()
}
/* -------------------------------------------------------------- Analyse --- */
const analyseSel = { projectId: '', range: 30 }
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 = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>'
return
}
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
analyseSel.projectId = project.projectId
root.innerHTML = `
${usable.length > 1 ? `
<div class="fn-project kb-panel">
<label for="an-project">Website</label>
<select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
</div>` : ''}
<div id="an-stage"></div>`
root.querySelector('#an-project')?.addEventListener('change', (ev) => {
analyseSel.projectId = ev.target.value
initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
})
const stage = root.querySelector('#an-stage')
const a = analyseOf(project)
const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
else if (a.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</p></div>'
else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></div>`
else renderAnalyseActivation(stage, project, reload)
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
<p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p>
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
</div>`
}
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 `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
}
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 = `
<div class="kb-panel an-panel">
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p>
<p>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.</p>
<h4 class="an-h4">Farbe des Banners</h4>
<div class="an-colors">
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
</div>
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
<div id="an-preview"></div>
${controllerBlock(controller)}
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>
<p class="error hidden" id="an-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
</div>`
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 '<p class="nw-empty">Noch keine Daten.</p>'
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 `
<svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title">
<title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))}</title>
${m.yTicks.map((t) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${t.y}" y2="${t.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${t.y + 4}" text-anchor="end">${esc(t.label)}</text>`).join('')}
${m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')}
<path class="an-area" d="${m.area}"/>
<path class="an-line" d="${m.line}"/>
${m.points.length <= 31 ? m.points.map((p) => `<circle class="an-dot" cx="${p.x}" cy="${p.y}" r="3"><title>${esc(p.day)}: ${esc(nf.format(p.pv))}</title></circle>`).join('') : ''}
</svg>`
}
function barList(title, items, labelOf, { html = false } = {}) {
const rows = barRows(items, labelOf)
return `
<section class="kb-panel an-list">
<h3>${esc(title)}</h3>
${rows.length ? `<ul class="an-rows">${rows.map((r) => `
<li><div class="an-row-top"><span class="an-row-label">${html ? r.label : esc(r.label)}</span><b>${esc(nf.format(r.count))}</b></div><div class="an-bar"><i style="width:${r.pct}%"></i></div></li>`).join('')}</ul>` : '<p class="nw-empty">Noch keine Daten.</p>'}
</section>`
}
const COUNTRY_ROW = (key) => `${flagEmoji(key) ? `<span aria-hidden="true">${flagEmoji(key)}</span> ` : ''}${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 = `
<div class="an-range" role="tablist" aria-label="Zeitraum">${[7, 30, 90].map((r) => `<button type="button" role="tab" class="an-tab${r === range ? ' active' : ''}" aria-selected="${r === range}" data-range="${r}">${r} Tage</button>`).join('')}</div>
<div id="an-data"><p class="kb-empty">Lade…</p></div>
<section class="kb-panel an-heat" id="an-heat"></section>
<section class="kb-panel an-off" id="an-off"></section>
<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p>`
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 = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
return
}
if (!stage.isConnected || myRequest !== statsRequestId) return
const t = stats.totals || {}
const rate = Math.round((Number(t.consentRate) || 0) * 100)
const kpi = (label, value) => `<div class="an-kpi"><span>${label}</span><b>${esc(value)}</b></div>`
const b = stats.banner || {}
data.innerHTML = `
<div class="an-kpis">${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} %`)}</div>
<section class="kb-panel an-chart-box"><h3>Seitenaufrufe pro Tag</h3>${lineChart(stats.series)}</section>
<div class="an-grid-lists">
${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]))}
</div>
<p class="an-note">${esc(KPI_NOTE)}</p>`
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 = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
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', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
} catch {
/* Text reicht */
}
}
function renderAnalyseHeat(box, projectId, pages) {
if (!box) return
if (!pages.length) {
box.innerHTML = '<h3>Heatmap</h3><p class="nw-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
return
}
if (!heatListening) {
window.addEventListener('message', onHeatMessage)
heatListening = true
}
const big = heatSel.big === 't' ? 't' : 'd'
box.innerHTML = `
<div class="hs-head">
<h3>Heatmap</h3>
<div class="hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button" class="an-tab${vp === big ? ' active' : ''}" data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
</div>
<p>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.</p>
<div class="an-heat-row">
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
</div>
<div class="hs-stage" data-big="${big}">
<figure class="hs-dev hs-dev--big"></figure>
<figure class="hs-dev hs-dev--phone"></figure>
</div>`
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 = `
<h3>Analyse ausschalten</h3>
<p>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.</p>
<p class="error hidden" id="an-off-error" role="alert"></p>
<div class="nw-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
const actions = box.querySelector('#an-off-actions')
const error = box.querySelector('#an-off-error')
box.querySelector('#an-off-ask').addEventListener('click', () => {
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
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 = `
<h3>Analyse ausgeschaltet</h3>
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
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')
}
})
})
}
+4 -1
View File
@@ -54,8 +54,11 @@ test('Portal: Analyse-Ansicht ist verdrahtet', () => {
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/)
assert.match(html, /id="view-analyse"/)
assert.match(html, /id="analyse-body"/)
assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/)
const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8')
assert.doesNotMatch(an, /\bconfirm\(/)
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/)
assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/)
})
test('Portal-Vorschau: Banner-Text ist der des Clients', () => {
+1 -1
View File
@@ -25,7 +25,7 @@ test('Entwurfsseite ohne Fassung ist nicht mehr als Datei vorhanden', () => {
})
test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => {
const fn = readFileSync(root('public/funktionen.js'), 'utf8')
const fn = readFileSync(root('public/analyse.js'), 'utf8')
for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) {
assert.ok(!fn.includes(old), old)
}
+5 -3
View File
@@ -5,15 +5,17 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet)
// public/funktionen.js wirklich laden (die absoluten Importe /fn-util.js und /analyse-view.js werden auf die Dateien umgeleitet);
// analyse.js importiert relativ und wird direkt geladen
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
const { initFunktionen } = await import(pathToFileURL(file).href)
const { initAnalyse, renderAnalyseOff } = await import(fileUrl('analyse.js'))
const news = { state: 'off' }
const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse })
+2 -2
View File
@@ -264,9 +264,9 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp
assert.match(html, /<section class="kb-view" id="view-termine">[\s\S]*<h1>Terminplaner<\/h1>[\s\S]*<div id="termine-body">/)
assert.match(html, /<link rel="stylesheet" href="\/termine\.css">/)
// R4: funktionen.js lädt keine Terminplaner-Module (die Kachel steht inline); seine absoluten Importe biegt
// test/funktionenTiles.test.js um – genau diese beiden (/geraete.js kam mit der Heatmap im Gerätrahmen auf main)
// test/funktionenTiles.test.js um – genau diese beiden (die Analyse-Oberfläche liegt in analyse.js, mit relativen Importen)
const fn = lies('public/funktionen.js')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"])
assert.doesNotMatch(fn, /from '\/termine/)
assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import')
})