/* Tab "Funktionen" und News-Pinnwand im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen).
initFunktionen(rootEl): Kacheln der Funktionen. initNews(rootEl, { projectId }): je nach Zustand der Funktion
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
const TITLE_MAX = 60
const TEXT_MAX = 400
const DURATIONS = [
['1w', '1 Woche'], ['2w', '2 Wochen'], ['1m', '1 Monat'], ['3m', '3 Monate'],
['open', 'Bis ich sie abnehme'], ['date', 'Bis zu einem Datum'],
]
const REHANG_DURATIONS = DURATIONS.filter(([key]) => key !== 'date')
const PAPERS = [['sun', 'Sonnengelb'], ['sky', 'Himmelblau'], ['coral', 'Koralle'], ['white', 'Weiß']]
const TYPES = [
['note', 'Text', 'ein Zettel', ' '],
['photo', 'Foto', 'ein Polaroid', ' '],
['combo', 'Foto + Text', 'beides', ' '],
]
const esc = (value) => String(value ?? '')
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''')
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
function fmtDay(value) {
const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
}
// "2026-10-01" in der Zeitzone Berlin, plus Tage (fuer min/max des Datumsfelds)
function berlinIsoDay(offsetDays = 0) {
const d = new Date(Date.now() + offsetDays * 86400000)
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: '' }
const newsOf = (project) => project.features?.news || { state: 'off' }
const isSetup = (state) => state === 'ordered' || state === 'setup'
// Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste
function pickProject(projects) {
return projects.find((p) => newsOf(p).state === 'active') || projects.find((p) => isSetup(newsOf(p).state)) || projects.find((p) => newsOf(p).state === 'off') || projects[0]
}
/* ------------------------------------------------------------ Kacheln --- */
const ICONS = {
news: ' ',
calendar: ' ',
chart: ' ',
}
const svg = (name) => `${ICONS[name]} `
function newsTile(projects) {
const states = projects.map((p) => newsOf(p).state)
const active = states.filter((s) => s === 'active').length
let pill = ['Aus', 'off']
let cta = `Einrichten lassen · ${PRICE}`
if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } else if (states.length && states.every((s) => s === 'disabled')) { pill = ['Abgeschaltet', 'off']; cta = 'Status ansehen' }
const target = pickProject(projects)
const count = projects.length > 1 && active ? `
aktiv für ${active} von ${projects.length} Websites
` : ''
return `
${svg('news')}${pill[0]}
News-Pinnwand
Schreib kurze Neuigkeiten mit Text und Foto – sie hängen wie Zettel an einer Pinnwand auf deiner Website.
${count}
${target
? `${cta} `
: 'Sobald deine Website angelegt ist, kannst du die Pinnwand buchen.
'}
`
}
/* 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 = `Auswertung ansehen `
else if (state === 'off') action = `Einrichten `
else if (a.reason === 'kein_abo') action = 'Im Wartungs-Abo enthalten. Zu Verträge
'
else action = `${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}
`
const count = projects.length > 1 && active ? `aktiv für ${active} von ${projects.length} Websites
` : ''
return `
${svg('chart')}${pill[0]}
Analyse
${ANALYSE_TEXT} Ohne Cookies, auf einem Server in Deutschland.
${count}
${action}
`
}
const soonTile = (icon, title, text) => `
${svg(icon)}Kommt bald
${title}
${text}
`
export async function initFunktionen(root) {
if (!root) return
const { projects = [] } = await request('/api/funktionen')
root.innerHTML = `
${newsTile(projects)}
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
${analyseTile(projects)}
`
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 --- */
export async function initNews(root, { projectId } = {}) {
if (!root) return
if (projectId) selection.projectId = projectId
const { projects = [], setupEstimate = '', consentText = '' } = await request('/api/funktionen')
if (!projects.length) {
root.innerHTML = 'Noch keine Website-Projekte zugewiesen.
'
return
}
const project = projects.find((p) => p.projectId === selection.projectId) || pickProject(projects)
selection.projectId = project.projectId
const feature = newsOf(project)
// Rueckkehr von Stripe (#news?bestellt=1): Flag nur einmal auswerten und aus der Adresse nehmen
const orderedFlag = /[?&]bestellt=1\b/.test(window.location.hash)
if (orderedFlag) window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#news`)
const ordered = orderedFlag && isSetup(feature.state)
// Editor nicht neu aufbauen, solange Projekt und Zustand gleich sind: Entwurf und hochgeladenes Foto bleiben
const renderKey = `${project.projectId}:${feature.state}`
if (feature.state === 'active' && root.dataset.newsKey === renderKey && root.querySelector('.nw')) return
root.dataset.newsKey = feature.state === 'active' ? renderKey : ''
root.innerHTML = `
${projects.length > 1 ? `
Website
${projects.map((p) => `${esc(p.projectName)} `).join('')}
` : ''}
`
root.querySelector('#news-project')?.addEventListener('change', (ev) => {
selection.projectId = ev.target.value
initNews(root).catch((err) => { root.innerHTML = `${esc(err.message)}
` })
})
const stage = root.querySelector('#news-stage')
const thanks = ordered
? 'Danke! Sobald die Zahlung da ist, legen wir los.
'
: ''
if (feature.state === 'active') renderEditor(stage, project)
else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate)
else if (feature.state === 'disabled') stage.innerHTML = 'News-Pinnwand ist abgeschaltet Für diese Website ist die Pinnwand im Moment nicht aktiv. Schreib uns im Chat , dann klären wir das.
'
else renderBooking(stage, project, { setupEstimate, consentText, thanks })
}
function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
stage.innerHTML = `
${thanks}
News-Pinnwand einrichten lassen
Wir bauen die Pinnwand in deine Website ${esc(project.projectName)} ein. Danach schreibst du deine Neuigkeiten selbst hier im Kundenbereich – mit Text, Foto und Anzeigedauer.
Einrichtung und Einbau durch uns
Beiträge jederzeit selbst schreiben, ändern und abnehmen
Nutzung danach kostenlos, kein Abo
${PRICE} einmalig, inklusive Mehrwertsteuer
${setupEstimate ? `
Einrichtung: ${esc(setupEstimate)}
` : ''}
Leistungsbeschreibung
Zahlungspflichtig bestellen · ${PRICE}
`
stage.querySelector('#news-consent-text').textContent = consentText
const box = stage.querySelector('#news-consent')
const button = stage.querySelector('#news-order')
const error = stage.querySelector('#news-order-error')
box.addEventListener('change', () => { button.disabled = !box.checked })
button.addEventListener('click', async () => {
if (!box.checked) return
button.disabled = true
error.classList.add('hidden')
try {
const { url } = await request('/api/funktionen/news/checkout', { method: 'POST', body: { projectId: project.projectId, consent: true } })
window.location.href = url
} catch (err) {
error.textContent = err.message
error.classList.remove('hidden')
button.disabled = !box.checked
}
})
}
function statusCard(feature, setupEstimate) {
const rows = [
feature.ticketWoid ? `
Ticketnummer #${esc(feature.ticketWoid)} ` : '',
feature.orderedAt ? `
Bestellt am ${esc(fmtDay(feature.orderedAt))} ` : '',
setupEstimate ? `
Zeiteinschätzung ${esc(setupEstimate)} ` : '',
].join('')
const invoice = safeUrl(feature.invoiceUrl)
return `
In Einrichtung
Wir richten deine News-Pinnwand ein
${feature.state === 'ordered' ? 'Sobald deine Zahlung bei uns ankommt, legen wir los.' : 'Sobald sie fertig ist, kannst du hier deine ersten Beiträge schreiben.'}
${rows ? `
${rows} ` : ''}
`
}
/* -------------------------------------------------------------- Editor --- */
function renderEditor(stage, project) {
const pid = project.projectId
const q = `projectId=${encodeURIComponent(pid)}`
const imageUrl = (imageId, size = 's') => `/api/news/image/${encodeURIComponent(pid)}/${encodeURIComponent(imageId)}-${size}.webp`
const fresh = () => ({ editId: '', type: 'combo', title: '', text: '', paper: 'sun', duration: '1m', date: '', imageId: '', imageSrc: '', uploading: false })
let ed = fresh()
let liveUrl = ''
let list = { hanging: [], expired: [] }
stage.innerHTML = `
So sieht dein Zettel aus
Vorschau: so sehen es alle
`
const $ = (sel) => stage.querySelector(sel)
const $$ = (sel) => [...stage.querySelectorAll(sel)]
const setErr = (field, message) => {
const target = $(`[data-err="${field}"]`) || $('[data-err="_"]')
if (target) target.textContent = message || ''
}
const clearErrs = () => $$('[data-err]').forEach((el) => { el.textContent = '' })
const errField = (field) => ({ imageId: 'photo', date: 'duration' }[field] || field || '_')
function previewHtml() {
const hasPhoto = ed.type !== 'note'
const photo = hasPhoto
? (ed.imageSrc ? ` ` : 'Foto ')
: ''
const title = ed.title.trim()
if (ed.type === 'photo') {
return `${photo}${esc(title)} Heute `
}
const note = `Heute ${title ? `
${esc(title)} ` : ''}
${esc(ed.text)}
`
return ed.type === 'combo' ? `${photo} ${note}
` : note
}
function sync() {
$$('.nw-type').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.type === ed.type)))
$$('.nw-sw').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.paper === ed.paper)))
$('#nw-photo-field').classList.toggle('hidden', ed.type === 'note')
$('#nw-text-field').classList.toggle('hidden', ed.type === 'photo')
$('#nw-paper-field').classList.toggle('hidden', ed.type === 'photo')
$('#nw-title-label').textContent = ed.type === 'photo' ? 'Bildunterschrift (freiwillig)' : 'Überschrift (freiwillig)'
$('#nw-count').textContent = `${ed.text.length} / ${TEXT_MAX}`
$('#nw-date').classList.toggle('hidden', ed.duration !== 'date')
$('#nw-step-title').textContent = ed.editId ? 'Zettel ändern' : 'Neuer Zettel'
$('#nw-publish').textContent = ed.editId ? 'Änderungen speichern' : 'Aufhängen'
$('#nw-cancel').classList.toggle('hidden', !ed.editId)
const img = $('#nw-drop-img')
img.classList.toggle('hidden', !ed.imageSrc)
if (ed.imageSrc) img.querySelector('img').src = ed.imageSrc
$('#nw-pick span').textContent = ed.imageSrc ? 'Anderes Foto wählen' : 'Foto auswählen'
$('#nw-publish').disabled = ed.uploading
$('#nw-preview').innerHTML = previewHtml()
}
function reset() {
ed = fresh()
$('#nw-title').value = ''
$('#nw-text').value = ''
$('#nw-duration').value = '1m'
$('#nw-date').value = ''
clearErrs()
sync()
}
// Formular
$$('.nw-type').forEach((b) => b.addEventListener('click', () => { ed.type = b.dataset.type; sync() }))
$$('.nw-sw').forEach((b) => b.addEventListener('click', () => { ed.paper = b.dataset.paper; sync() }))
$('#nw-title').addEventListener('input', (ev) => { ed.title = ev.target.value; sync() })
$('#nw-text').addEventListener('input', (ev) => { ed.text = ev.target.value; sync() })
$('#nw-duration').addEventListener('change', (ev) => { ed.duration = ev.target.value; sync() })
$('#nw-date').addEventListener('change', (ev) => { ed.date = ev.target.value })
$('#nw-prev-toggle').addEventListener('click', (ev) => {
const open = $('#nw-prev-box').classList.toggle('is-open')
ev.currentTarget.setAttribute('aria-expanded', String(open))
})
$('#nw-cancel').addEventListener('click', reset)
// Foto: gleich hochladen, der Server liefert die imageId
$$('#nw-photo-field input[type=file]').forEach((input) => input.addEventListener('change', async () => {
const file = input.files[0]
input.value = ''
if (!file) return
setErr('photo', '')
if (file.size > PHOTO_MAX_BYTES) { setErr('photo', 'Das Foto ist größer als 10 MB.'); return }
const previous = { imageId: ed.imageId, imageSrc: ed.imageSrc }
ed.uploading = true
ed.imageSrc = URL.createObjectURL(file)
sync()
$('#nw-photo-hint').textContent = 'Foto wird hochgeladen…'
try {
const { imageId } = await request(`/api/news/image?${q}`, { method: 'POST', raw: file })
ed.imageId = imageId
} catch (err) {
setErr('photo', err.message)
Object.assign(ed, previous)
} finally {
ed.uploading = false
$('#nw-photo-hint').textContent = 'JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.'
sync()
}
}))
$('#nw-publish').addEventListener('click', async () => {
clearErrs()
$('#nw-done').classList.add('hidden')
const body = { type: ed.type, title: ed.title.trim(), text: ed.type === 'photo' ? '' : ed.text, paper: ed.paper, duration: ed.duration }
if (ed.duration === 'date') body.date = ed.date
if (ed.type !== 'note') body.imageId = ed.imageId
const wasEdit = Boolean(ed.editId)
const button = $('#nw-publish')
button.disabled = true
try {
if (wasEdit) await request(`/api/news/${encodeURIComponent(ed.editId)}?${q}`, { method: 'PATCH', body })
else await request('/api/news', { method: 'POST', body: { ...body, projectId: pid } })
reset()
const done = $('#nw-done')
done.textContent = wasEdit ? 'Gespeichert.' : 'Hängt jetzt an deiner Pinnwand.'
done.classList.remove('hidden')
await loadList()
} catch (err) {
setErr(errField(err.field), err.message)
} finally {
button.disabled = ed.uploading
}
})
// Listen "Hängt gerade" / "Abgelaufen"
const untilText = (p) => (p.visibleUntil ? `bis ${fmtDay(p.visibleUntil)}` : 'bis du sie abnimmst')
function rowHtml(p, expired) {
const label = p.title || (p.text ? p.text.slice(0, 60) : 'Foto')
const thumb = p.imageId
? ` `
: `T `
const meta = expired
? (p.takenDownAt ? `Abgenommen am ${fmtDay(p.takenDownAt)}` : `Abgelaufen am ${fmtDay(p.visibleUntil)}`)
: `Seit ${fmtDay(p.visibleFrom)} · ${untilText(p)}`
const actions = expired
? `${REHANG_DURATIONS.map(([k, l]) => `${l} `).join('')}
Wieder aufhängen `
: 'Ändern Abnehmen '
return `
${thumb}
${esc(label)} ${esc(meta)}
${actions}Löschen
`
}
function renderLists() {
const box = $('#nw-lists')
const site = safeUrl(liveUrl)
box.innerHTML = `
${list.hanging.length ? `${list.hanging.map((p) => rowHtml(p, false)).join('')} ` : 'Im Moment hängt nichts. Schreib deinen ersten Zettel links.
'}
${list.expired.length ? `Abgelaufen Nicht mehr auf deiner Website zu sehen. Du kannst sie wieder aufhängen.
${list.expired.map((p) => rowHtml(p, true)).join('')} ` : ''}`
box.querySelectorAll('.nw-row').forEach(wireRow)
}
function wireRow(row) {
const id = row.dataset.id
const all = [...list.hanging, ...list.expired]
const post = all.find((p) => p.$id === id)
const rowErr = row.querySelector('.nw-row-err')
const run = async (fn) => {
rowErr.textContent = ''
try {
await fn()
await loadList()
} catch (err) {
rowErr.textContent = err.message
}
}
row.querySelector('[data-edit]')?.addEventListener('click', () => startEdit(post))
row.querySelector('[data-takedown]')?.addEventListener('click', () => run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'takeDown' } })))
row.querySelector('[data-rehang]')?.addEventListener('click', () => {
const duration = row.querySelector('.nw-rehang-dur').value
run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'rehang', duration } }))
})
// Loeschen mit Rueckfrage in der Zeile (kein confirm())
row.querySelector('[data-del]').addEventListener('click', () => {
const actions = row.querySelector('.nw-row-actions')
actions.innerHTML = 'Wirklich löschen? Ja, löschen Abbrechen '
actions.querySelector('[data-cancel]').addEventListener('click', renderLists)
actions.querySelector('[data-yes]').addEventListener('click', () => run(async () => {
await request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'DELETE' })
if (ed.editId === id) reset()
}))
})
}
function startEdit(post) {
clearErrs()
$('#nw-done').classList.add('hidden')
ed = {
editId: post.$id, type: post.type, title: post.title || '', text: post.text || '', paper: post.paper,
duration: post.duration, date: post.duration === 'date' && post.visibleUntil ? new Date(post.visibleUntil).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) : '',
imageId: post.imageId || '', imageSrc: post.imageId ? imageUrl(post.imageId, 'l') : '', uploading: false,
}
$('#nw-title').value = ed.title
$('#nw-text').value = ed.text
$('#nw-duration').value = ed.duration
$('#nw-date').value = ed.date
sync()
$('#nw-step-title').scrollIntoView({ block: 'start', behavior: 'smooth' })
}
async function loadList() {
try {
const data = await request(`/api/news?${q}`)
list = { hanging: data.hanging || [], expired: data.expired || [] }
liveUrl = data.liveUrl || ''
renderLists()
} catch (err) {
$('#nw-lists').innerHTML = `Beiträge konnten nicht geladen werden: ${esc(err.message)}
`
}
}
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 = 'Die Analyse ist für deine Websites noch nicht freigeschaltet.
'
return
}
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
analyseSel.projectId = project.projectId
root.innerHTML = `
${usable.length > 1 ? `
Website
${usable.map((p) => `${esc(p.projectName)} `).join('')}
` : ''}
`
root.querySelector('#an-project')?.addEventListener('change', (ev) => {
analyseSel.projectId = ev.target.value
initAnalyse(root).catch((err) => { root.innerHTML = `${esc(err.message)}
` })
})
const stage = root.querySelector('#an-stage')
const a = analyseOf(project)
const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `${esc(err.message)}
` })
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
else if (a.state === 'disabled') stage.innerHTML = 'Analyse ist abgeschaltet Für diese Website ist die Analyse im Moment gesperrt. Schreib uns im Chat , dann klären wir das.
'
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' ? ' Zu Verträge ' : ''}
`
else renderAnalyseActivation(stage, project, reload)
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: Datenschutz
Ablehnen Annehmen
`
}
function renderAnalyseActivation(stage, project, reload) {
const a = analyseOf(project)
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. Wir speichern keine IP-Adressen und setzen keine Cookies . Alles läuft auf einem eigenen Server in Deutschland.
Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.
Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.
Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.
Farbe des Banners
Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).
Ich beauftrage WEBklar mit der Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag (Fassung ${esc(a.avvVersion)}). AVV lesen
Analyse einschalten
`
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 'Noch keine Daten.
'
const total = m.points.reduce((sum, p) => sum + p.pv, 0)
const first = m.points[0].day
const last = m.points[m.points.length - 1].day
return `
Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))}
${m.yTicks.map((t) => `${esc(t.label)} `).join('')}
${m.xLabels.map((l) => `${esc(l.label)} `).join('')}
${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))} `).join('') : ''}
`
}
function barList(title, items, labelOf, { html = false } = {}) {
const rows = barRows(items, labelOf)
return `
${esc(title)}
${rows.length ? `` : 'Noch keine Daten.
'}
`
}
const COUNTRY_ROW = (key) => `${flagEmoji(key) ? `${flagEmoji(key)} ` : ''}${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 = `
${[7, 30, 90].map((r) => `${r} Tage `).join('')}
Länderdaten: IP-Geolocation by DB-IP
`
stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) }))
renderAnalyseOff(stage.querySelector('#an-off'), pid, reload)
const data = stage.querySelector('#an-data')
let stats
try {
stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
} catch (err) {
if (!stage.isConnected) return
data.innerHTML = `${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}
`
return
}
if (!stage.isConnected) return
const t = stats.totals || {}
const rate = Math.round((Number(t.consentRate) || 0) * 100)
const kpi = (label, value) => `${label} ${esc(value)}
`
const b = stats.banner || {}
data.innerHTML = `
${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}
Seitenaufrufe pro Tag ${lineChart(stats.series)}
${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')}
${barList('Herkunft', stats.refs, refLabel)}
${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })}
${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')}
${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)}
${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))}
${esc(KPI_NOTE)}
`
renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || [])
}
function renderAnalyseHeat(box, projectId, pages) {
if (!box) return
if (!pages.length) {
box.innerHTML = 'Heatmap Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.
'
return
}
box.innerHTML = `
Heatmap
Sieh auf deiner Website, wo Besucher klicken und wie weit sie scrollen.
Seite${pages.map((p) => `${esc(p.key || '/')} `).join('')}
Gerät${Object.entries(DEVICE_LABELS).map(([k, v]) => `${v} `).join('')}
Auf meiner Website ansehen
`
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 = `
Analyse ausschalten
Danach werden keine neuen Daten mehr gesammelt und der Banner verschwindet von deiner Website. Die bisherigen Daten bleiben noch 60 Tage erhalten.
Analyse ausschalten
`
const actions = box.querySelector('#an-off-actions')
const error = box.querySelector('#an-off-error')
box.querySelector('#an-off-ask').addEventListener('click', () => {
actions.innerHTML = 'Wirklich ausschalten? Ja, ausschalten Abbrechen '
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')
}
})
})
}