Analyse: Portal-Kachel und Auswertung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc937b0d1e
commit
5a6fbc7328
6 files changed
+483
-4
No files matched your search
+266
-2
@@ -3,7 +3,10 @@
|
||||
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
|
||||
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
|
||||
|
||||
import { bannerColor, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
|
||||
|
||||
const PRICE = '30 €'
|
||||
const AVV_URL = '/rechtliches/avv-website-analyse' // Platzhalter: das Dokument liefert Task 13
|
||||
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
|
||||
const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um
|
||||
const PHOTO_MAX_BYTES = 10 * 1024 * 1024
|
||||
@@ -95,6 +98,38 @@ function newsTile(projects) {
|
||||
</article>`
|
||||
}
|
||||
|
||||
/* 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 === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
|
||||
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)
|
||||
if (!target || a.comingSoon) return soonTile('chart', 'Analyse', ANALYSE_TEXT)
|
||||
const state = analyseState(a)
|
||||
const active = projects.filter((p) => analyseState(analyseOf(p)) === 'active').length
|
||||
const pill = state === 'active' ? ['Aktiv', 'on'] : ['Aus', 'off']
|
||||
let action
|
||||
if (state === 'active') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Auswertung ansehen</button>`
|
||||
else if (state === 'off') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Einrichten</button>`
|
||||
else if (a.reason === 'kein_abo') action = '<p class="fn-count">Im Wartungs-Abo enthalten. <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button></p>'
|
||||
else action = `<p class="fn-count">${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}</p>`
|
||||
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||||
return `
|
||||
<article class="fn-tile" data-fn-tile="analyse">
|
||||
<div class="fn-tile-head">${svg('chart')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||
<h3>Analyse</h3>
|
||||
<p>${ANALYSE_TEXT} Ohne Cookies, auf einem Server in Deutschland.</p>
|
||||
${count}
|
||||
${action}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const soonTile = (icon, title, text) => `
|
||||
<article class="fn-tile fn-tile-soon">
|
||||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||||
@@ -109,9 +144,10 @@ export async function initFunktionen(root) {
|
||||
<div class="fn-tiles">
|
||||
${newsTile(projects)}
|
||||
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
|
||||
${soonTile('chart', 'Analyse', 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.')}
|
||||
${analyseTile(projects)}
|
||||
</div>`
|
||||
root.querySelector('.fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project })
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- News --- */
|
||||
@@ -505,3 +541,231 @@ 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.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">Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: <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>`
|
||||
}
|
||||
|
||||
function renderAnalyseActivation(stage, project, reload) {
|
||||
const a = analyseOf(project)
|
||||
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. Wir speichern <strong>keine IP-Adressen</strong> und setzen <strong>keine Cookies</strong>. Alles läuft auf einem eigenen Server in Deutschland.</p>
|
||||
<ul class="kb-checks">
|
||||
<li>Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.</li>
|
||||
<li>Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.</li>
|
||||
<li>Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.</li>
|
||||
</ul>
|
||||
<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>
|
||||
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag (Fassung ${esc(a.avvVersion)}). <a href="${esc(AVV_URL)}" target="_blank" rel="noopener">AVV lesen</a></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 })
|
||||
button.addEventListener('click', async () => {
|
||||
if (!box.checked) 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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))}`
|
||||
|
||||
async function renderAnalyseStats(stage, project, reload) {
|
||||
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) return
|
||||
data.innerHTML = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
|
||||
return
|
||||
}
|
||||
if (!stage.isConnected) 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 || [])
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
box.innerHTML = `
|
||||
<h3>Heatmap</h3>
|
||||
<p>Sieh auf deiner Website, wo Besucher klicken und wie weit sie scrollen.</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>
|
||||
<label>Gerät<select id="an-heat-vp">${Object.entries(DEVICE_LABELS).map(([k, v]) => `<option value="${k}"${k === 'm' ? ' selected' : ''}>${v}</option>`).join('')}</select></label>
|
||||
</div>
|
||||
<p class="error hidden" id="an-heat-error" role="alert"></p>
|
||||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-heat-go">Auf meiner Website ansehen</button></div>`
|
||||
const go = box.querySelector('#an-heat-go')
|
||||
const error = box.querySelector('#an-heat-error')
|
||||
go.addEventListener('click', async () => {
|
||||
go.disabled = true
|
||||
error.classList.add('hidden')
|
||||
try {
|
||||
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path: box.querySelector('#an-heat-page').value || '/', vp: box.querySelector('#an-heat-vp').value } })
|
||||
if (safeUrl(url)) window.open(url, '_blank', 'noopener')
|
||||
} catch (err) {
|
||||
error.textContent = err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message
|
||||
error.classList.remove('hidden')
|
||||
} finally {
|
||||
go.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
|
||||
function renderAnalyseOff(box, projectId, reload) {
|
||||
box.innerHTML = `
|
||||
<h3>Analyse ausschalten</h3>
|
||||
<p>Danach werden keine neuen Daten mehr gesammelt und der Banner verschwindet von deiner Website. Die bisherigen Daten bleiben noch 60 Tage erhalten.</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 } })
|
||||
reload()
|
||||
} catch (err) {
|
||||
renderAnalyseOff(box, projectId, reload)
|
||||
const e2 = box.querySelector('#an-off-error')
|
||||
e2.textContent = err.message
|
||||
e2.classList.remove('hidden')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user