/* 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, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt 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' } // Preis, Knopf und Zustimmungstext der News-Buchung aus dem Register (features.news der Antwort von /api/funktionen); // fehlt das Register oder ein Wert, gelten die bisherigen Werte (PRICE und der consentText der Antwort) const textOr = (value, fallback) => (typeof value === 'string' && value.trim() ? value : fallback) function newsOffer(features, consentFallback = '') { const reg = features?.news const price = textOr(reg?.priceLabel, PRICE) return { price, knopf: textOr(reg?.consent?.knopf, `Zahlungspflichtig bestellen · ${price}`), consentText: textOr(reg?.consent?.items?.[0]?.text, consentFallback), } } 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, price = PRICE) { const states = projects.map((p) => newsOf(p).state) const active = states.filter((s) => s === 'active').length let pill = ['Aus', 'off'] let cta = `Einrichten lassen · ${esc(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 DEV_HINT = 'Im Dev-Modus wird nicht gemessen.' 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 devHint = state === 'active' && a.devMode ? `

${DEV_HINT}

` : '' 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} ${devHint} ${action}
` } /* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst (termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. termine (Antwort von /api/funktionen) traegt bei verfuegbar: true bestellbar (true | false | null = Abo nicht lesbar), grund (kein_abo | gekuendigt | nicht_aktiv | null), preis und einrichtung. Das Abo betrifft nur die Bestellung: ohne laufendes Abo gibt es im Zustand Aus keinen Weg in die Ansicht; wer schon bestellt hat oder den Terminplaner nutzt, behaelt seinen. */ const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.' const termineOf = (project) => project.features?.termine || { state: 'off' } // Doppelt zu anfragenText in termine-view.js: funktionen.js darf dort nichts importieren (die Kachel testet test/funktionenTiles.test.js // mit einem umgebogenen Import). Aenderungen an beiden Stellen machen. const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`) // Abo-Hinweise der Kachel. Kopie von aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte) fuer den // Terminplaner; test/funktionenTiles.test.js haelt beide gleich. kein_abo steht wie bei der Analyse im Wortlaut der Kachel. const TERMINE_ABO_TEXTE = { gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.', nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.', unbekannt: 'Das Abo konnte gerade nicht geprüft werden.', } const KEIN_ABO_HINWEIS = 'Im Wartungs-Abo enthalten. ' function termineAboHinweis(angebot) { if (angebot.bestellbar !== false) return TERMINE_ABO_TEXTE.unbekannt // null (Lesefehler) oder Feld fehlt: nicht geprueft if (angebot.grund === 'kein_abo') return KEIN_ABO_HINWEIS return Object.hasOwn(TERMINE_ABO_TEXTE, angebot.grund) && angebot.grund !== 'unbekannt' ? TERMINE_ABO_TEXTE[angebot.grund] : TERMINE_ABO_TEXTE.unbekannt } // Entwurf: Funktionsdokument im Zustand Aus mit siteKey (entsteht beim ersten Speichern des Fragebogens) const hatEntwurf = (project) => termineOf(project).state === 'off' && Boolean(termineOf(project).siteKey) // Zielprojekt: aktiv, dann ordered/setup, dann abgeschaltet, dann Entwurf, dann das erste function pickTermineProject(projects) { return projects.find((p) => termineOf(p).state === 'active') || projects.find((p) => isSetup(termineOf(p).state)) || projects.find((p) => termineOf(p).state === 'disabled') || projects.find(hatEntwurf) || projects[0] } function termineTile(projects, angebot = {}) { const active = projects.filter((p) => termineOf(p).state === 'active').length const target = pickTermineProject(projects) const state = target ? termineOf(target).state : 'off' const knopf = (text) => `` let pill = ['Aus', 'off'] let extra = '' let action = '' if (state === 'active') { pill = ['Aktiv', 'on'] extra = '' action = knopf('Termine öffnen') } else if (state === 'ordered') { pill = ['Zahlung offen', 'setup'] action = knopf('Status ansehen') } else if (state === 'setup') { pill = ['In Einrichtung', 'setup'] action = knopf('Status ansehen') } else if (state === 'disabled') { pill = ['Abgeschaltet', 'off'] extra = '

Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.

' action = knopf('Termine ansehen') } else if (!target) { extra = '

Sobald deine Website angelegt ist, kannst du den Terminplaner einrichten.

' } else if (angebot.bestellbar === true) { const preis = typeof angebot.preis === 'string' && angebot.preis.trim() ? ` · ${esc(angebot.preis)}` : '' action = knopf(hatEntwurf(target) ? 'Fragebogen fortsetzen' : `Terminplaner einrichten${preis}`) } else { extra = `

${termineAboHinweis(angebot)}

` } const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' return `
${svg('calendar')}${pill[0]}

Terminplaner

${TERMINE_TEXT}

${extra} ${count} ${action}
` } // "{n} Anfragen warten" aus anfragenOffen der aktiven Websites; ein Fehler laesst die Kachel einfach ohne Zahl stehen async function showTermineCount(root, projects) { const el = root.querySelector('[data-termine-count]') const active = projects.filter((p) => termineOf(p).state === 'active') if (!el || !active.length) return const counts = await Promise.all(active.map((p) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(p.projectId)}`).then((d) => Number(d.anfragenOffen) || 0, () => 0))) const open = counts.reduce((sum, n) => sum + n, 0) if (open < 1) return el.textContent = anfragenText(open) el.hidden = false } const soonTile = (icon, title, text) => `
${svg(icon)}Kommt bald

${title}

${text}

` export async function initFunktionen(root) { if (!root) return const { projects = [], termine, features } = await request('/api/funktionen') root.innerHTML = `
${newsTile(projects, newsOffer(features).price)} ${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)} ${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 }) // Die Terminplaner-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (termine.js, initTermine) root.querySelector('[data-fn-tile="termine"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { const body = document.getElementById('termine-body') if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project }) if (termine?.verfuegbar) await showTermineCount(root, projects) } /* ---------------------------------------------------------------- News --- */ export async function initNews(root, { projectId } = {}) { if (!root) return if (projectId) selection.projectId = projectId const { projects = [], setupEstimate = '', consentText = '', features } = 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, offer: newsOffer(features, consentText), thanks }) } function renderBooking(stage, project, { setupEstimate, offer, 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

${esc(offer.price)} einmalig, inklusive Mehrwertsteuer

${setupEstimate ? `

Einrichtung: ${esc(setupEstimate)}

` : ''}
` stage.querySelector('#news-consent-text').textContent = offer.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 ` ` } 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 `` return `

    Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

    ${controllerLines(controller).map(esc).join(' · ')}

    Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

    ` } export function renderAnalyseActivation(stage, project, reload) { const a = analyseOf(project) const legal = a.legal || {} const controller = a.controller || { ok: false } let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' stage.innerHTML = `

    Analyse für ${esc(project.projectName)} einschalten

    Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

    Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.

    Farbe des Banners

    ${PRESET_COLORS.map((c) => ``).join('')}
    ${controllerBlock(controller)}
    ` const input = stage.querySelector('#an-color') const hint = stage.querySelector('#an-hint') const paint = () => { stage.querySelector('#an-preview').innerHTML = bannerPreview(color) hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) } paint() input.addEventListener('input', () => { color = input.value; paint() }) stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) const box = stage.querySelector('#an-avv') const button = stage.querySelector('#an-on') const error = stage.querySelector('#an-error') box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) button.addEventListener('click', async () => { if (!box.checked || controller.ok !== true) return button.disabled = true error.classList.add('hidden') try { await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) reload() } catch (err) { error.textContent = err.message error.classList.remove('hidden') button.disabled = !box.checked || controller.ok !== true } }) } 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 || []) } /* 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 = `
    ${esc(heatCaption('loading'))}
    ` 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.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', ` In neuem Tab öffnen`) } catch { /* Text reicht */ } } 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 } if (!heatListening) { window.addEventListener('message', onHeatMessage) heatListening = true } const big = heatSel.big === 't' ? 't' : 'd' box.innerHTML = `

    Heatmap

    ${['d', 't'].map((vp) => ``).join('')}

    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.

    ` 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('active', on) x.setAttribute('aria-pressed', String(on)) }) stage.dataset.big = heatSel.big show(bigFig, heatSel.big) })) heatSel.big = big // Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite) const start = () => { show(bigFig, heatSel.big) show(phoneFig, 'm') } if (typeof IntersectionObserver !== 'function') return start() const io = new IntersectionObserver((entries) => { if (!entries.some((e) => e.isIntersecting)) return io.disconnect() start() }, { rootMargin: '300px 0px' }) io.observe(stage) } // Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog export function renderAnalyseOff(box, projectId, reload) { box.innerHTML = `

    Analyse ausschalten

    Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

    ` 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 } }) box.innerHTML = `

    Analyse ausgeschaltet

    Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

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