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)
}
// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens, today = "Heute bisher" aus der Statistik
function headHtml(project, usable, { showRange, stamp = '', today } = {}) {
const a = analyseOf(project)
return headerHtml({ project, usable, range: RANGES.includes(analyseSel.range) ? analyseSel.range : 30, showRange, devMode: a.state === 'active' && Boolean(a.devMode), stamp, today })
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
${esc(BANNER_PREVIEW_TEXT)}Datenschutz
AblehnenAnnehmen
`
}
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 `
${esc(MISSING_PRIVACY_HINT)}
`
return `
Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:
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('')}
Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).
${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
}
})
}
/* --- Aktiver Zustand: Reiter Uebersicht | Heatmap | Einstellungen ---
Zustand je Aufruf von initAnalyse in der Closure von mountCockpit; Ereignisse am Wurzelelement .an (Kopfleiste und Reiter werden
neu gezeichnet, ohne dass Handler neu gebunden werden muessen). statsRequestId verwirft veraltete Antworten, sobald etwas Neueres
angefordert oder der Reiter gewechselt wurde. */
let statsRequestId = 0
function hideChartTip() {
document.querySelectorAll('.an-dot.on').forEach((d) => d.classList.remove('on'))
document.querySelectorAll('.an-tip').forEach((t) => { t.hidden = true })
}
let tipListening = false
// Diagramm: Zeigen oder Tippen waehlt den naechsten Tag, die Blase zeigt "Sa., 19.09.: 371 Aufrufe (davor 210)"
function wireChart(stage) {
const svg = stage.querySelector('.an-chart')
const tip = stage.querySelector('.an-tip')
const wrap = svg?.parentElement
const dots = svg ? [...svg.querySelectorAll('.an-dot')] : []
if (!tip || !wrap || !dots.length) return
if (!tipListening) {
// Antippen ausserhalb schliesst die Blase (auf Touch bleibt sie sonst nach dem Loslassen stehen)
document.addEventListener('pointerdown', (ev) => { if (!ev.target?.closest?.('.an-chart')) hideChartTip() })
tipListening = true
}
const xs = dots.map((d) => Number(d.getAttribute('cx')))
const show = (ev) => {
const ctm = svg.getScreenCTM()
if (!ctm) return
const x = new DOMPoint(ev.clientX, ev.clientY).matrixTransform(ctm.inverse()).x
let best = 0
for (let i = 1; i < xs.length; i++) if (Math.abs(xs[i] - x) < Math.abs(xs[best] - x)) best = i
const dot = dots[best]
dots.forEach((d) => d.classList.toggle('on', d === dot))
tip.textContent = chartTipText(dot.dataset.day, dot.dataset.pv, dot.dataset.prev)
tip.hidden = false
const box = wrap.getBoundingClientRect()
const at = dot.getBoundingClientRect()
const half = tip.offsetWidth / 2
tip.style.left = `${Math.min(Math.max(at.left + at.width / 2 - box.left, half), box.width - half)}px`
tip.style.top = `${at.top + at.height / 2 - box.top}px`
tip.classList.toggle('below', at.top - box.top < tip.offsetHeight + 16)
}
svg.addEventListener('pointermove', show)
svg.addEventListener('pointerdown', show)
svg.addEventListener('pointerleave', (ev) => { if (ev.pointerType === 'mouse') hideChartTip() })
svg.addEventListener('pointercancel', hideChartTip)
}
function mountCockpit({ an, stage, project, usable, reload }) {
const pid = project.projectId
const state = { stats: null, range: 0, stamp: '', rank: { seen: {}, who: {} } }
const tabNow = () => (TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht')
const rangeNow = () => (RANGES.includes(analyseSel.range) ? analyseSel.range : 30)
const current = (mine) => stage.isConnected && mine === statsRequestId
// Kopfleiste neu zeichnen (Zeitraum nur in der Uebersicht, Stand); Fokus auf einem Zeitraum-Knopf bleibt
function paintHead() {
const bar = an.querySelector('.an-top')
if (!bar) return
const focused = bar.contains(document.activeElement) ? document.activeElement.dataset.range : ''
bar.outerHTML = headHtml(project, usable, { showRange: tabNow() === 'uebersicht', stamp: state.stamp, today: state.stats?.today })
if (focused) an.querySelector(`.an-seg [data-range="${focused}"]`)?.focus()
}
// Reiterknoepfe in place umschalten (Fokus bleibt), nur der gewaehlte ist per Tab erreichbar, die anderen per Pfeiltaste
function paintTabs() {
const tab = tabNow()
an.querySelectorAll('.an-tabbtn').forEach((b) => {
const on = b.dataset.tab === tab
b.classList.toggle('on', on)
b.setAttribute('aria-selected', String(on))
b.tabIndex = on ? 0 : -1
})
stage.setAttribute('aria-labelledby', `an-tab-${tab}`)
}
// Anfrage je Zeitraum: Uebersicht und Heatmap teilen sich eine laufende Anfrage. Sie schreibt nichts in state; uebernommen
// (adopt) wird die Antwort erst nach current(mine), sonst ueberschriebe eine verspaetete den Stand des neueren Zeitraums.
const pending = {}
function fetchStats(range) {
if (!pending[range]) {
const p = request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
const done = () => { if (pending[range] === p) delete pending[range] }
p.then(done, done)
pending[range] = p
}
return pending[range]
}
function adopt(stats, range) {
state.stats = stats
state.range = range
state.stamp = CLOCK.format(new Date())
}
function paintOverview() {
stage.innerHTML = overviewHtml(state.stats)
for (const card of Object.keys(state.rank)) if (state.rank[card].tab) paintRank(card)
wireChart(stage)
}
// Rangliste einer Karte neu zeichnen (nur diese Karte); der Fokus kehrt auf den gewaehlten Umschalter zurueck
function paintRank(card, { focus = false } = {}) {
const old = stage.querySelector(`[data-rank="${card}"]`)
if (!old) return
old.outerHTML = rankCardHtml(card, state.stats, state.rank[card])
if (focus) stage.querySelector(`[data-rank="${card}"] .an-ltab.on`)?.focus()
}
async function showOverview() {
const mine = ++statsRequestId
const range = rangeNow()
if (state.stats && state.range === range) {
paintOverview()
return
}
stage.innerHTML = skeletonHtml()
let stats
try {
stats = await fetchStats(range)
} catch (err) {
if (!current(mine)) return
stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
return
}
if (!current(mine)) return
adopt(stats, range)
paintHead()
paintOverview()
}
// Heatmap: Seitenliste aus der zuletzt geladenen Statistik, sonst einmal 30 Tage laden; iframes erst jetzt
async function showHeat() {
const mine = ++statsRequestId
stage.innerHTML = '
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.
${['d', 't'].map((vp) => ``).join('')}
`
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('on', 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.