/* 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) => `` 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 ? `` : '

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 === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : 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'] : state === 'disabled' ? ['Gesperrt', 'off'] : ['Aus', 'off'] let action let text = `${ANALYSE_TEXT} Ohne Cookies, auf einem Server in Deutschland.` if (state === 'unknown') action = '

Die Daten konnten gerade nicht geladen werden.

' else if (state === 'disabled') { text = 'Bitte melde dich bei uns'; action = '' } else if (state === 'active') action = `` else if (state === 'off') action = `` else if (a.reason === 'kein_abo') action = '

Im Wartungs-Abo enthalten.

' 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

${text}

${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[data-project]')?.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 ? `
` : ''}
` 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. , 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)}

` : ''}
` 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}
` : ''}
${invoice ? `Rechnung ansehen` : ''}
` } /* -------------------------------------------------------------- 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 = `

Neuer Zettel

Was möchtest du aufhängen?

${TYPES.map(([key, label, sub, icon]) => ` `).join('')}

JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.

Kurz und herzlich wirkt am besten.0 / ${TEXT_MAX}

${PAPERS.map(([key, label]) => ``).join('')}

Vorschau: so sehen es alle

Lade…

` 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 ? ` ` : '' return `
  • ${thumb}
    ${esc(label)}${esc(meta)}
    ${actions}
  • ` } function renderLists() { const box = $('#nw-lists') const site = safeUrl(liveUrl) box.innerHTML = `

    Hängt gerade

    ${site ? `Auf meiner Website ansehen` : ''}
    ${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?' 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 ? `
    ` : ''}
    ` 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. , 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) } function bannerPreview(color) { const c = bannerColor(color) const btn = `style="background:${c};border-color:${c};color:#fff"` return ` ` } 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

    ${PRESET_COLORS.map((c) => ``).join('')}
    ` 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 ? `
      ${rows.map((r) => `
    • ${html ? r.label : esc(r.label)}${esc(nf.format(r.count))}
    • `).join('')}
    ` : '

    Noch keine Daten.

    '}
    ` } const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${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 = `
    ${[7, 30, 90].map((r) => ``).join('')}

    Lade…

    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 || myRequest !== statsRequestId) return data.innerHTML = `` return } if (!stage.isConnected || myRequest !== statsRequestId) 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.

    ` 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') box.querySelector('#an-heat-link').innerHTML = '' 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 } }) // Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt const link = box.querySelector('#an-heat-link') link.innerHTML = safeUrl(url) ? `Heatmap öffnen` : '' } 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.

    ` 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? ' 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') } }) }) }