Final-Review-Fixwelle der Cockpit-Übersicht:
- F1 Collector: Der Zeitraum sind die range Kalendertage bis gestern (Berliner Tag),
der Vorzeitraum die range Tage davor; heute steht getrennt in today { day, pv, visitors,
contacts }. Kein Einbruch mehr am rechten Rand von Diagramm und Mini-Verläufen,
kein falsches "x % weniger" tagsüber. Portal: weekdayStats bekommt die ganze Reihe
(completedDays entfällt), "Vergleich ab" = firstDay + 2 x range, Kopfzeile
"Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr" (alter Collector ohne today
bleibt ohne "Heute bisher").
- F2 "direkt kontaktiert" zählt nur Telefon, E-Mail und WhatsApp (auch im Vorzeitraum),
Links nach außen nicht.
- F3 Mini-Verlauf "Ø Zeit pro Seite": Tage ohne Messung sind Lücken statt 0-Tälern.
- F4 Fehlerfarbe beim Ausschalten rot, --an-faint auf 55 %, Achsenschrift per
Container-Abfragen auf 11-12 px (y-Rand wächst mit der Stellenzahl, x-Beschriftungen mit
Mindestabstand), Ranglisten-Anteile an allen Seitenaufrufen, Tippflächen bis 880 px
mindestens 40 px hoch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
26 KiB
JavaScript
518 lines
26 KiB
JavaScript
/* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen).
|
|
initAnalyse(rootEl, { projectId }): Rahmen "Cockpit" (.an) mit Kopfleiste; je nach Zustand der Analyse Einschalten (off),
|
|
Hinweis (disabled/blocked/unknown) oder, bei aktiver Analyse, die Reiter Uebersicht | Heatmap | Einstellungen.
|
|
HTML-Bausteine: analyse-cards.js, reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: analyse.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 } from './analyse-view.js'
|
|
import { headerHtml, tabsHtml, overviewHtml, rankCardHtml, skeletonHtml, errorHtml, settingsHtml, chartTipText } from './analyse-cards.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 TABS = ['uebersicht', 'heatmap', 'einstellungen']
|
|
const RANGES = [7, 30, 90]
|
|
const CLOCK = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
|
|
const showError = (root) => (err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` }
|
|
|
|
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="an"><section class="an-card an-state"><p class="an-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</p></section></div>'
|
|
return
|
|
}
|
|
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
|
|
analyseSel.projectId = project.projectId
|
|
const a = analyseOf(project)
|
|
const active = a.state === 'active'
|
|
const tab = TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht'
|
|
root.innerHTML = `<div class="an">${headHtml(project, usable, { showRange: active && tab === 'uebersicht' })}${active ? tabsHtml(tab) : ''}<div id="an-stage"${active ? ` role="tabpanel" aria-labelledby="an-tab-${tab}"` : ''}></div></div>`
|
|
const stage = root.querySelector('#an-stage')
|
|
const reload = () => initAnalyse(root).catch(showError(root))
|
|
// Website wechseln: der Reiter bleibt (analyseSel.tab)
|
|
root.querySelector('.an')?.addEventListener('change', (ev) => {
|
|
if (ev.target?.id !== 'an-project') return
|
|
analyseSel.projectId = ev.target.value
|
|
reload()
|
|
})
|
|
if (a.state === 'active') mountCockpit({ an: root.querySelector('.an'), stage, project, usable, reload })
|
|
else if (a.state === 'disabled') stage.innerHTML = '<section class="an-card an-state"><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></section>'
|
|
else if (a.state === 'unknown') stage.innerHTML = '<section class="an-card an-state"><p>Die Daten konnten gerade nicht geladen werden.</p></section>'
|
|
else if (!a.eligible) stage.innerHTML = `<section class="an-card an-state"><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></section>`
|
|
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 `
|
|
<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="an-card 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
|
|
}
|
|
})
|
|
}
|
|
|
|
/* --- 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 = '<div class="an-ov"><section class="an-card an-heat" id="an-heat"></section></div>'
|
|
const box = stage.querySelector('#an-heat')
|
|
if (!state.stats) {
|
|
box.innerHTML = '<h3>Heatmap</h3><p class="an-empty">Lade…</p>'
|
|
let stats
|
|
try {
|
|
stats = await fetchStats(30)
|
|
} 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, 30)
|
|
paintHead()
|
|
}
|
|
renderAnalyseHeat(box, pid, state.stats.pages || [])
|
|
}
|
|
function showSettings() {
|
|
statsRequestId += 1
|
|
stage.innerHTML = settingsHtml(project)
|
|
renderAnalyseOff(stage.querySelector('#an-off'), pid, reload)
|
|
}
|
|
function showTab() {
|
|
paintTabs()
|
|
paintHead()
|
|
const tab = tabNow()
|
|
if (tab === 'heatmap') showHeat()
|
|
else if (tab === 'einstellungen') showSettings()
|
|
else showOverview()
|
|
}
|
|
function pickTab(tab) {
|
|
if (!TABS.includes(tab) || tab === tabNow()) return
|
|
hideChartTip()
|
|
analyseSel.tab = tab
|
|
showTab()
|
|
}
|
|
|
|
an.addEventListener('click', (ev) => {
|
|
const btn = ev.target?.closest?.('button')
|
|
if (!btn || !an.contains(btn)) return
|
|
if (btn.dataset.range && btn.closest('.an-seg')) {
|
|
// Zeitraum: nur die Uebersicht laedt neu
|
|
const range = Number(btn.dataset.range)
|
|
if (!RANGES.includes(range) || range === rangeNow()) return
|
|
analyseSel.range = range
|
|
paintHead()
|
|
showOverview()
|
|
} else if (btn.dataset.tab && (btn.closest('.an-tabs') || btn.closest('.an-teaser'))) {
|
|
pickTab(btn.dataset.tab)
|
|
} else if (btn.id === 'an-retry') {
|
|
showTab()
|
|
} else if (btn.closest('.an-rank')) {
|
|
const card = btn.closest('.an-rank').dataset.rank
|
|
const rank = state.rank[card]
|
|
if (!rank) return
|
|
if (btn.dataset.rankTab) Object.assign(rank, { tab: btn.dataset.rankTab, expanded: false })
|
|
else if (btn.hasAttribute('data-rank-more')) rank.expanded = true
|
|
else return
|
|
paintRank(card, { focus: true })
|
|
}
|
|
})
|
|
// Pfeiltasten zwischen den Reitern (Reiter wechselt mit dem Fokus)
|
|
an.addEventListener('keydown', (ev) => {
|
|
if (!ev.target?.closest?.('.an-tabbtn')) return
|
|
const tabs = [...an.querySelectorAll('.an-tabbtn')]
|
|
const at = tabs.indexOf(ev.target.closest('.an-tabbtn'))
|
|
const next = { ArrowRight: (at + 1) % tabs.length, ArrowLeft: (at + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[ev.key]
|
|
if (next === undefined) return
|
|
ev.preventDefault()
|
|
tabs[next].focus()
|
|
pickTab(tabs[next].dataset.tab)
|
|
})
|
|
showTab()
|
|
}
|
|
|
|
/* 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.isConnected && 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="an-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>
|
|
<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-bar">
|
|
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
|
|
<div class="an-seg hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button"${vp === big ? ' class="on"' : ''} data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
|
|
</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('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 = `
|
|
<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="an-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')
|
|
}
|
|
})
|
|
})
|
|
}
|