785 lines
44 KiB
JavaScript
785 lines
44 KiB
JavaScript
/* 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.html'
|
||
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', '<path d="M12 20h9"/><path d="M16.4 3.6a1 1 0 0 1 3 3L7.4 18.6a2 2 0 0 1-.9.5l-2.9.8a.5.5 0 0 1-.6-.6l.8-2.9a2 2 0 0 1 .5-.9z"/>'],
|
||
['photo', 'Foto', 'ein Polaroid', '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3z"/><circle cx="12" cy="13" r="3"/>'],
|
||
['combo', 'Foto + Text', 'beides', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'],
|
||
]
|
||
|
||
const esc = (value) => String(value ?? '')
|
||
.replace(/&/g, '&').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: '<path d="M12 17v5"/><path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.2V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.8a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
||
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
|
||
}
|
||
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
|
||
|
||
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 ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||
const devHint = state === 'active' && a.devMode ? `<p class="fn-count an-dev-hint">${DEV_HINT}</p>` : ''
|
||
return `
|
||
<article class="fn-tile" data-fn-tile="news">
|
||
<div class="fn-tile-head">${svg('news')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||
<h3>News-Pinnwand</h3>
|
||
<p>Schreib kurze Neuigkeiten mit Text und Foto – sie hängen wie Zettel an einer Pinnwand auf deiner Website.</p>
|
||
${count}
|
||
${target
|
||
? `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="news" data-project="${esc(target.projectId)}">${cta}</button>`
|
||
: '<p class="fn-count">Sobald deine Website angelegt ist, kannst du die Pinnwand buchen.</p>'}
|
||
</article>`
|
||
}
|
||
|
||
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
|
||
const analyseOf = (project) => project.analyse || { comingSoon: true }
|
||
const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === '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 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 = '<p class="fn-count">Die Daten konnten gerade nicht geladen werden.</p>'
|
||
else if (state === 'disabled') { text = 'Bitte melde dich bei uns'; action = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>' }
|
||
else if (state === 'active') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Auswertung ansehen</button>`
|
||
else if (state === 'off') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Einrichten</button>`
|
||
else if (a.reason === 'kein_abo') action = '<p class="fn-count">Im Wartungs-Abo enthalten. <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button></p>'
|
||
else action = `<p class="fn-count">${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}</p>`
|
||
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||
return `
|
||
<article class="fn-tile" data-fn-tile="analyse">
|
||
<div class="fn-tile-head">${svg('chart')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||
<h3>Analyse</h3>
|
||
<p>${text}</p>
|
||
${count}
|
||
${devHint}
|
||
${action}
|
||
</article>`
|
||
}
|
||
|
||
const soonTile = (icon, title, text) => `
|
||
<article class="fn-tile fn-tile-soon">
|
||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||
<h3>${title}</h3>
|
||
<p>${text}</p>
|
||
</article>`
|
||
|
||
export async function initFunktionen(root) {
|
||
if (!root) return
|
||
const { projects = [] } = await request('/api/funktionen')
|
||
root.innerHTML = `
|
||
<div class="fn-tiles">
|
||
${newsTile(projects)}
|
||
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
|
||
${analyseTile(projects)}
|
||
</div>`
|
||
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 = '<div class="kb-panel kb-empty">Noch keine Website-Projekte zugewiesen.</div>'
|
||
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 ? `
|
||
<div class="fn-project kb-panel">
|
||
<label for="news-project">Website</label>
|
||
<select id="news-project">${projects.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
|
||
</div>` : ''}
|
||
<div id="news-stage"></div>`
|
||
root.querySelector('#news-project')?.addEventListener('change', (ev) => {
|
||
selection.projectId = ev.target.value
|
||
initNews(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
|
||
})
|
||
const stage = root.querySelector('#news-stage')
|
||
const thanks = ordered
|
||
? '<p class="fn-thanks" role="status">Danke! Sobald die Zahlung da ist, legen wir los.</p>'
|
||
: ''
|
||
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 = '<div class="kb-panel"><h3>News-Pinnwand ist abgeschaltet</h3><p>Für diese Website ist die Pinnwand im Moment nicht aktiv. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
|
||
else renderBooking(stage, project, { setupEstimate, consentText, thanks })
|
||
}
|
||
|
||
function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
|
||
stage.innerHTML = `
|
||
${thanks}
|
||
<div class="kb-panel fn-booking">
|
||
<h3>News-Pinnwand einrichten lassen</h3>
|
||
<p>Wir bauen die Pinnwand in deine Website <strong>${esc(project.projectName)}</strong> ein. Danach schreibst du deine Neuigkeiten selbst hier im Kundenbereich – mit Text, Foto und Anzeigedauer.</p>
|
||
<ul class="kb-checks">
|
||
<li>Einrichtung und Einbau durch uns</li>
|
||
<li>Beiträge jederzeit selbst schreiben, ändern und abnehmen</li>
|
||
<li>Nutzung danach kostenlos, kein Abo</li>
|
||
</ul>
|
||
<p class="fn-price"><strong>${PRICE}</strong> <span>einmalig, inklusive Mehrwertsteuer</span></p>
|
||
${setupEstimate ? `<p class="fn-estimate">Einrichtung: ${esc(setupEstimate)}</p>` : ''}
|
||
<p class="fn-desc-link"><a href="${LEISTUNGSBESCHREIBUNG}" target="_blank" rel="noopener">Leistungsbeschreibung</a></p>
|
||
<label class="fn-consent"><input type="checkbox" id="news-consent"><span id="news-consent-text"></span></label>
|
||
<p class="error hidden" id="news-order-error" role="alert"></p>
|
||
<div class="abo-actions"><button type="button" class="abo-btn" id="news-order" disabled>Zahlungspflichtig bestellen · ${PRICE}</button></div>
|
||
</div>`
|
||
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 ? `<div><dt>Ticketnummer</dt><dd>#${esc(feature.ticketWoid)}</dd></div>` : '',
|
||
feature.orderedAt ? `<div><dt>Bestellt am</dt><dd>${esc(fmtDay(feature.orderedAt))}</dd></div>` : '',
|
||
setupEstimate ? `<div><dt>Zeiteinschätzung</dt><dd>${esc(setupEstimate)}</dd></div>` : '',
|
||
].join('')
|
||
const invoice = safeUrl(feature.invoiceUrl)
|
||
return `
|
||
<div class="kb-panel fn-status">
|
||
<span class="fn-pill fn-pill-setup">In Einrichtung</span>
|
||
<h3>Wir richten deine News-Pinnwand ein</h3>
|
||
<p>${feature.state === 'ordered' ? 'Sobald deine Zahlung bei uns ankommt, legen wir los.' : 'Sobald sie fertig ist, kannst du hier deine ersten Beiträge schreiben.'}</p>
|
||
${rows ? `<dl class="fn-facts">${rows}</dl>` : ''}
|
||
<div class="abo-actions">
|
||
${invoice ? `<a class="kb-btn-outline" href="${esc(invoice)}" target="_blank" rel="noopener">Rechnung ansehen</a>` : ''}
|
||
<button type="button" class="kb-btn-outline" data-view="chat">Frage dazu? Chat öffnen</button>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
/* -------------------------------------------------------------- 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 = `
|
||
<div class="nw">
|
||
<div class="nw-form">
|
||
<div class="kb-panel nw-card">
|
||
<p class="nw-step" id="nw-step-title">Neuer Zettel</p>
|
||
<h3>Was möchtest du aufhängen?</h3>
|
||
<div class="nw-types">
|
||
${TYPES.map(([key, label, sub, icon]) => `
|
||
<button type="button" class="nw-type" data-type="${key}" aria-pressed="false">
|
||
<span class="nw-type-ico"><svg viewBox="0 0 24 24" aria-hidden="true">${icon}</svg></span>
|
||
${label}<small>${sub}</small>
|
||
</button>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="kb-panel nw-card">
|
||
<div class="nw-field" id="nw-photo-field">
|
||
<label>Foto</label>
|
||
<div class="nw-drop" id="nw-drop">
|
||
<div class="nw-drop-img hidden" id="nw-drop-img"><img alt=""></div>
|
||
<div class="nw-drop-btns">
|
||
<label class="kb-btn-outline nw-only-phone"><input type="file" accept="${PHOTO_ACCEPT}" capture="environment" hidden>Foto aufnehmen</label>
|
||
<label class="kb-btn-outline" id="nw-pick"><input type="file" accept="${PHOTO_ACCEPT}" hidden><span>Foto auswählen</span></label>
|
||
</div>
|
||
<p class="nw-hint" id="nw-photo-hint">JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.</p>
|
||
</div>
|
||
<p class="nw-err" data-err="photo" role="alert"></p>
|
||
</div>
|
||
<div class="nw-field">
|
||
<label for="nw-title" id="nw-title-label">Überschrift (freiwillig)</label>
|
||
<input type="text" id="nw-title" maxlength="${TITLE_MAX}" autocomplete="off">
|
||
<p class="nw-err" data-err="title" role="alert"></p>
|
||
</div>
|
||
<div class="nw-field" id="nw-text-field">
|
||
<label for="nw-text">Text</label>
|
||
<textarea id="nw-text" rows="4" maxlength="${TEXT_MAX}"></textarea>
|
||
<p class="nw-hint nw-hint-row"><span>Kurz und herzlich wirkt am besten.</span><span id="nw-count">0 / ${TEXT_MAX}</span></p>
|
||
<p class="nw-err" data-err="text" role="alert"></p>
|
||
</div>
|
||
<div class="nw-field" id="nw-paper-field">
|
||
<label>Zettelfarbe</label>
|
||
<div class="nw-swatches">
|
||
${PAPERS.map(([key, label]) => `<button type="button" class="nw-sw nw-paper-${key}" data-paper="${key}" aria-pressed="false" aria-label="${label}" title="${label}"></button>`).join('')}
|
||
</div>
|
||
<p class="nw-err" data-err="paper" role="alert"></p>
|
||
</div>
|
||
<div class="nw-field">
|
||
<label for="nw-duration">Wie lange soll der Zettel hängen?</label>
|
||
<select id="nw-duration">${DURATIONS.map(([key, label]) => `<option value="${key}"${key === '1m' ? ' selected' : ''}>${label}</option>`).join('')}</select>
|
||
<input type="date" id="nw-date" class="hidden" min="${berlinIsoDay(1)}" max="${berlinIsoDay(365)}" aria-label="Letzter Tag">
|
||
<p class="nw-err" data-err="duration" role="alert"></p>
|
||
</div>
|
||
<p class="nw-err" data-err="_" role="alert"></p>
|
||
<p class="fn-thanks hidden" id="nw-done" role="status"></p>
|
||
<div class="abo-actions nw-actions">
|
||
<button type="button" class="abo-btn" id="nw-publish">Aufhängen</button>
|
||
<button type="button" class="kb-btn-outline hidden" id="nw-cancel">Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="nw-side">
|
||
<button type="button" class="kb-btn-outline nw-prev-toggle" id="nw-prev-toggle" aria-expanded="false">So sieht dein Zettel aus</button>
|
||
<div class="kb-panel nw-prev" id="nw-prev-box">
|
||
<p class="nw-prev-label"><i></i>Vorschau: so sehen es alle</p>
|
||
<div id="nw-preview"></div>
|
||
</div>
|
||
<div class="kb-panel nw-lists" id="nw-lists"><p class="kb-empty">Lade…</p></div>
|
||
</div>
|
||
</div>`
|
||
|
||
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 ? `<img src="${esc(ed.imageSrc)}" alt="">` : '<span class="nw-ph">Foto</span>')
|
||
: ''
|
||
const title = ed.title.trim()
|
||
if (ed.type === 'photo') {
|
||
return `<figure class="nw-polaroid">${photo}<figcaption><b>${esc(title)}</b><span>Heute</span></figcaption></figure>`
|
||
}
|
||
const note = `<div class="nw-paper nw-paper-${ed.paper}"><span class="nw-date">Heute</span>${title ? `<h4>${esc(title)}</h4>` : ''}<p>${esc(ed.text)}</p></div>`
|
||
return ed.type === 'combo' ? `<div class="nw-combo"><figure class="nw-polaroid">${photo}</figure>${note}</div>` : 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
|
||
? `<img class="nw-thumb" src="${esc(imageUrl(p.imageId))}" alt="">`
|
||
: `<span class="nw-thumb nw-paper-${esc(p.paper)}">T</span>`
|
||
const meta = expired
|
||
? (p.takenDownAt ? `Abgenommen am ${fmtDay(p.takenDownAt)}` : `Abgelaufen am ${fmtDay(p.visibleUntil)}`)
|
||
: `Seit ${fmtDay(p.visibleFrom)} · ${untilText(p)}`
|
||
const actions = expired
|
||
? `<select class="nw-rehang-dur" aria-label="Anzeigedauer">${REHANG_DURATIONS.map(([k, l]) => `<option value="${k}"${k === '1m' ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||
<button type="button" data-rehang>Wieder aufhängen</button>`
|
||
: '<button type="button" data-edit>Ändern</button><button type="button" data-takedown>Abnehmen</button>'
|
||
return `
|
||
<li class="nw-row" data-id="${esc(p.$id)}">
|
||
${thumb}
|
||
<div class="nw-row-text"><b>${esc(label)}</b><small>${esc(meta)}</small></div>
|
||
<div class="nw-row-actions">${actions}<button type="button" class="nw-del" data-del>Löschen</button></div>
|
||
<p class="nw-err nw-row-err" role="alert"></p>
|
||
</li>`
|
||
}
|
||
|
||
function renderLists() {
|
||
const box = $('#nw-lists')
|
||
const site = safeUrl(liveUrl)
|
||
box.innerHTML = `
|
||
<div class="nw-lists-head">
|
||
<h3>Hängt gerade</h3>
|
||
${site ? `<a class="kb-btn-outline" href="${esc(site)}" target="_blank" rel="noopener">Auf meiner Website ansehen</a>` : ''}
|
||
</div>
|
||
${list.hanging.length ? `<ul class="nw-rows">${list.hanging.map((p) => rowHtml(p, false)).join('')}</ul>` : '<p class="nw-empty">Im Moment hängt nichts. Schreib deinen ersten Zettel links.</p>'}
|
||
${list.expired.length ? `<h3 class="nw-expired-title">Abgelaufen</h3><p class="nw-hint">Nicht mehr auf deiner Website zu sehen. Du kannst sie wieder aufhängen.</p><ul class="nw-rows">${list.expired.map((p) => rowHtml(p, true)).join('')}</ul>` : ''}`
|
||
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 = '<span class="nw-confirm"><span>Wirklich löschen?</span><button type="button" class="nw-del" data-yes>Ja, löschen</button><button type="button" data-cancel>Abbrechen</button></span>'
|
||
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 = `<p class="muted">Beiträge konnten nicht geladen werden: ${esc(err.message)}</p>`
|
||
}
|
||
}
|
||
|
||
sync()
|
||
return loadList()
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Analyse --- */
|
||
const analyseSel = { projectId: '', range: 30 }
|
||
const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c']
|
||
const HEX_RE = /^#[0-9a-f]{6}$/i
|
||
const nf = new Intl.NumberFormat('de-DE')
|
||
const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.'
|
||
|
||
export async function initAnalyse(root, { projectId } = {}) {
|
||
if (!root) return
|
||
if (projectId) analyseSel.projectId = projectId
|
||
const { projects = [] } = await request('/api/funktionen')
|
||
const usable = projects.filter((p) => !analyseOf(p).comingSoon)
|
||
if (!usable.length) {
|
||
root.innerHTML = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>'
|
||
return
|
||
}
|
||
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
|
||
analyseSel.projectId = project.projectId
|
||
root.innerHTML = `
|
||
${usable.length > 1 ? `
|
||
<div class="fn-project kb-panel">
|
||
<label for="an-project">Website</label>
|
||
<select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
|
||
</div>` : ''}
|
||
<div id="an-stage"></div>`
|
||
root.querySelector('#an-project')?.addEventListener('change', (ev) => {
|
||
analyseSel.projectId = ev.target.value
|
||
initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
|
||
})
|
||
const stage = root.querySelector('#an-stage')
|
||
const a = analyseOf(project)
|
||
const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
|
||
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
|
||
else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
|
||
else if (a.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</p></div>'
|
||
else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></div>`
|
||
else renderAnalyseActivation(stage, project, reload)
|
||
}
|
||
|
||
function bannerPreview(color) {
|
||
const c = bannerColor(color)
|
||
const btn = `style="background:${c};border-color:${c};color:#fff"`
|
||
return `
|
||
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
|
||
<p class="an-bn-text">Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: <span class="an-bn-link">Datenschutz</span></p>
|
||
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
|
||
</div>`
|
||
}
|
||
|
||
function renderAnalyseActivation(stage, project, reload) {
|
||
const a = analyseOf(project)
|
||
let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111'
|
||
stage.innerHTML = `
|
||
<div class="kb-panel an-panel">
|
||
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
|
||
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen. Wir speichern <strong>keine IP-Adressen</strong> und setzen <strong>keine Cookies</strong>. Alles läuft auf einem eigenen Server in Deutschland.</p>
|
||
<ul class="kb-checks">
|
||
<li>Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.</li>
|
||
<li>Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.</li>
|
||
<li>Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.</li>
|
||
</ul>
|
||
<h4 class="an-h4">Farbe des Banners</h4>
|
||
<div class="an-colors">
|
||
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
|
||
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
|
||
</div>
|
||
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
|
||
<div id="an-preview"></div>
|
||
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag (Fassung ${esc(a.avvVersion)}). <a href="${esc(AVV_URL)}" target="_blank" rel="noopener">AVV lesen</a></span></label>
|
||
<p class="error hidden" id="an-error" role="alert"></p>
|
||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
|
||
</div>`
|
||
const input = stage.querySelector('#an-color')
|
||
const hint = stage.querySelector('#an-hint')
|
||
const paint = () => {
|
||
stage.querySelector('#an-preview').innerHTML = bannerPreview(color)
|
||
hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase())
|
||
}
|
||
paint()
|
||
input.addEventListener('input', () => { color = input.value; paint() })
|
||
stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() }))
|
||
const box = stage.querySelector('#an-avv')
|
||
const button = stage.querySelector('#an-on')
|
||
const error = stage.querySelector('#an-error')
|
||
box.addEventListener('change', () => { button.disabled = !box.checked })
|
||
button.addEventListener('click', async () => {
|
||
if (!box.checked) return
|
||
button.disabled = true
|
||
error.classList.add('hidden')
|
||
try {
|
||
await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } })
|
||
reload()
|
||
} catch (err) {
|
||
error.textContent = err.message
|
||
error.classList.remove('hidden')
|
||
button.disabled = !box.checked
|
||
}
|
||
})
|
||
}
|
||
|
||
function lineChart(series) {
|
||
const m = chartModel(series)
|
||
if (!m.points.length) return '<p class="nw-empty">Noch keine Daten.</p>'
|
||
const total = m.points.reduce((sum, p) => sum + p.pv, 0)
|
||
const first = m.points[0].day
|
||
const last = m.points[m.points.length - 1].day
|
||
return `
|
||
<svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title">
|
||
<title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))}</title>
|
||
${m.yTicks.map((t) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${t.y}" y2="${t.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${t.y + 4}" text-anchor="end">${esc(t.label)}</text>`).join('')}
|
||
${m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')}
|
||
<path class="an-area" d="${m.area}"/>
|
||
<path class="an-line" d="${m.line}"/>
|
||
${m.points.length <= 31 ? m.points.map((p) => `<circle class="an-dot" cx="${p.x}" cy="${p.y}" r="3"><title>${esc(p.day)}: ${esc(nf.format(p.pv))}</title></circle>`).join('') : ''}
|
||
</svg>`
|
||
}
|
||
|
||
function barList(title, items, labelOf, { html = false } = {}) {
|
||
const rows = barRows(items, labelOf)
|
||
return `
|
||
<section class="kb-panel an-list">
|
||
<h3>${esc(title)}</h3>
|
||
${rows.length ? `<ul class="an-rows">${rows.map((r) => `
|
||
<li><div class="an-row-top"><span class="an-row-label">${html ? r.label : esc(r.label)}</span><b>${esc(nf.format(r.count))}</b></div><div class="an-bar"><i style="width:${r.pct}%"></i></div></li>`).join('')}</ul>` : '<p class="nw-empty">Noch keine Daten.</p>'}
|
||
</section>`
|
||
}
|
||
|
||
const COUNTRY_ROW = (key) => `${flagEmoji(key) ? `<span aria-hidden="true">${flagEmoji(key)}</span> ` : ''}${esc(countryName(key))}`
|
||
|
||
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 = `
|
||
<div class="an-range" role="tablist" aria-label="Zeitraum">${[7, 30, 90].map((r) => `<button type="button" role="tab" class="an-tab${r === range ? ' active' : ''}" aria-selected="${r === range}" data-range="${r}">${r} Tage</button>`).join('')}</div>
|
||
<div id="an-data"><p class="kb-empty">Lade…</p></div>
|
||
<section class="kb-panel an-heat" id="an-heat"></section>
|
||
<section class="kb-panel an-off" id="an-off"></section>
|
||
<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p>`
|
||
stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) }))
|
||
renderAnalyseOff(stage.querySelector('#an-off'), pid, reload)
|
||
|
||
const data = stage.querySelector('#an-data')
|
||
let stats
|
||
try {
|
||
stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
|
||
} catch (err) {
|
||
if (!stage.isConnected || myRequest !== statsRequestId) return
|
||
data.innerHTML = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
|
||
return
|
||
}
|
||
if (!stage.isConnected || myRequest !== statsRequestId) return
|
||
const t = stats.totals || {}
|
||
const rate = Math.round((Number(t.consentRate) || 0) * 100)
|
||
const kpi = (label, value) => `<div class="an-kpi"><span>${label}</span><b>${esc(value)}</b></div>`
|
||
const b = stats.banner || {}
|
||
data.innerHTML = `
|
||
<div class="an-kpis">${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}</div>
|
||
<section class="kb-panel an-chart-box"><h3>Seitenaufrufe pro Tag</h3>${lineChart(stats.series)}</section>
|
||
<div class="an-grid-lists">
|
||
${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')}
|
||
${barList('Herkunft', stats.refs, refLabel)}
|
||
${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })}
|
||
${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')}
|
||
${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)}
|
||
${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))}
|
||
</div>
|
||
<p class="an-note">${esc(KPI_NOTE)}</p>`
|
||
renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || [])
|
||
}
|
||
|
||
function renderAnalyseHeat(box, projectId, pages) {
|
||
if (!box) return
|
||
if (!pages.length) {
|
||
box.innerHTML = '<h3>Heatmap</h3><p class="nw-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
|
||
return
|
||
}
|
||
box.innerHTML = `
|
||
<h3>Heatmap</h3>
|
||
<p>Sieh auf deiner Website, wo Besucher klicken und wie weit sie scrollen.</p>
|
||
<div class="an-heat-row">
|
||
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
|
||
<label>Gerät<select id="an-heat-vp">${Object.entries(DEVICE_LABELS).map(([k, v]) => `<option value="${k}"${k === 'm' ? ' selected' : ''}>${v}</option>`).join('')}</select></label>
|
||
</div>
|
||
<p class="error hidden" id="an-heat-error" role="alert"></p>
|
||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-heat-go">Auf meiner Website ansehen</button></div>
|
||
<div class="abo-actions" id="an-heat-link" aria-live="polite"></div>`
|
||
const go = box.querySelector('#an-heat-go')
|
||
const error = box.querySelector('#an-heat-error')
|
||
go.addEventListener('click', async () => {
|
||
go.disabled = true
|
||
error.classList.add('hidden')
|
||
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) ? `<a class="abo-btn" href="${esc(url)}" target="_blank" rel="noopener">Heatmap öffnen</a>` : ''
|
||
} catch (err) {
|
||
error.textContent = err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message
|
||
error.classList.remove('hidden')
|
||
} finally {
|
||
go.disabled = false
|
||
}
|
||
})
|
||
}
|
||
|
||
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
|
||
function renderAnalyseOff(box, projectId, reload) {
|
||
box.innerHTML = `
|
||
<h3>Analyse ausschalten</h3>
|
||
<p>Danach werden keine neuen Daten mehr gesammelt und der Banner verschwindet von deiner Website. Die bisherigen Daten bleiben noch 60 Tage erhalten.</p>
|
||
<p class="error hidden" id="an-off-error" role="alert"></p>
|
||
<div class="nw-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
|
||
const actions = box.querySelector('#an-off-actions')
|
||
const error = box.querySelector('#an-off-error')
|
||
box.querySelector('#an-off-ask').addEventListener('click', () => {
|
||
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
|
||
actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload))
|
||
actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => {
|
||
ev.currentTarget.disabled = true
|
||
try {
|
||
await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } })
|
||
reload()
|
||
} catch (err) {
|
||
renderAnalyseOff(box, projectId, reload)
|
||
const e2 = box.querySelector('#an-off-error')
|
||
e2.textContent = err.message
|
||
e2.classList.remove('hidden')
|
||
}
|
||
})
|
||
})
|
||
}
|