K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name. Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1158 lines
65 KiB
JavaScript
1158 lines
65 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, 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'
|
||
import { ZU_VERTRAEGE } from './vertraege-link.js'
|
||
|
||
const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt
|
||
// Rueckfall fuer den Preishinweis, wenn die Antwort von /api/funktionen kein ustHinweis hat (gleicher Wortlaut wie config.ustHinweis)
|
||
const UST_HINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG'
|
||
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' }
|
||
|
||
// 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: '<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"/>',
|
||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||
}
|
||
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
|
||
|
||
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 ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</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>`
|
||
}
|
||
|
||
// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn, und die Ansicht
|
||
// #termine (termine.js) nimmt Text, Ziel und Klasse aus derselben Quelle (vertraege-link.js)
|
||
const ZU_VERTRAEGE_LINK = `<button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>`
|
||
|
||
/* 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 ? `<p class="fn-count an-dev-hint">${DEV_HINT}</p>` : ''
|
||
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. ${ZU_VERTRAEGE_LINK}</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>`
|
||
}
|
||
|
||
/* 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, einrichtung und hinweis (Abo-Text des Servers, aboMeldung in
|
||
server/services/featureOrder.js, bei bestellbar true null). 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`)
|
||
|
||
// 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) => `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">${text}</button>`
|
||
let pill = ['Aus', 'off']
|
||
let extra = ''
|
||
let action = ''
|
||
if (state === 'active') {
|
||
pill = ['Aktiv', 'on']
|
||
extra = '<p class="fn-count" data-termine-count hidden></p>'
|
||
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 = '<p>Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.</p>'
|
||
action = knopf('Termine ansehen')
|
||
} else if (!target) {
|
||
extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du den Terminplaner einrichten.</p>'
|
||
} 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 {
|
||
// Hinweis des Servers (maskiert), bei kein_abo dazu der Weg in die Verträge; ohne Text bleibt nur der Link oder nichts
|
||
const hinweis = typeof angebot.hinweis === 'string' ? esc(angebot.hinweis) : ''
|
||
const zeile = [hinweis, angebot.bestellbar === false && angebot.grund === 'kein_abo' ? ZU_VERTRAEGE_LINK : ''].filter(Boolean).join(' ')
|
||
if (zeile) extra = `<p class="fn-count">${zeile}</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="termine">
|
||
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||
<h3>Terminplaner</h3>
|
||
<p>${TERMINE_TEXT}</p>
|
||
${extra}
|
||
${count}
|
||
${action}
|
||
</article>`
|
||
}
|
||
|
||
// "{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
|
||
}
|
||
|
||
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
|
||
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
|
||
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
|
||
sagt grund warum: kein_abo | gekuendigt | nicht_aktiv | null: Abo nicht lesbar) und preis; je Projekt features.zeiten (state, einbau, ticketWoid,
|
||
aboEndeAt, disabledReason, loeschungAm), zeitenStatus ({ offen, kurz, text } des Hauptplans) und zeitenLeer (keine Plaene gespeichert).
|
||
Ruling R20: nicht_aktiv (Probezeit, Zahlung offen) ist einschaltbar, sobald das Abo aktiv ist, und bietet kein Bezahlen an. */
|
||
const ZEITEN_TEXT = 'Deine Website zeigt live, ob du gerade geöffnet hast – mit Feiertagen, Urlaub und Hinweisen.'
|
||
const ZEITEN_ABGESCHALTET = 'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.'
|
||
const ZEITEN_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte später erneut.'
|
||
const zeitenOf = (project) => project.features?.zeiten || { state: 'off' }
|
||
|
||
// Zielprojekt: aktiv, dann in Einrichtung/Zahlung, dann abgeschaltet, sonst das erste
|
||
function pickZeitenProject(projects) {
|
||
return projects.find((p) => zeitenOf(p).state === 'active')
|
||
|| projects.find((p) => isSetup(zeitenOf(p).state))
|
||
|| projects.find((p) => zeitenOf(p).state === 'disabled')
|
||
|| projects[0]
|
||
}
|
||
|
||
// Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail K29);
|
||
// ein unlesbares Datum gibt '' (dann steht kein Hinweis da, nie ein erfundenes Datum). Doppelt zu zeiten.js (funktionen.js importiert dort nichts).
|
||
function zeitenFrist(aboEndeAt) {
|
||
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
|
||
if (Number.isNaN(ms)) return ''
|
||
const [y, m, d] = new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }).split('-').map(Number)
|
||
return new Date(Date.UTC(y, m - 1, d + 30)).toLocaleDateString('de-DE', { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
}
|
||
|
||
// Behalten (30 €) ist nur bezahlbar, wenn das Wartungs-Abo wirklich beendet ist: inklusive false UND grund kein_abo (Z12, R20, R25). nicht_aktiv und
|
||
// gekuendigt heissen, dass das Abo laeuft (Probezeit/Zahlung offen bzw. Kuendigung vorgemerkt): 'abo', nie ein Bezahlknopf. Doppelt zu behaltenLage in
|
||
// zeiten-bestellen.js (funktionen.js importiert dort nichts); beide pinnen die Tests. 'bezahlen' | 'abo' | 'inklusive' | 'unklar' (Abo nicht lesbar)
|
||
function zeitenBehaltenLage(angebot) {
|
||
if (angebot.inklusive === true) return 'inklusive'
|
||
if (angebot.inklusive !== false) return 'unklar'
|
||
if (angebot.grund === 'kein_abo') return 'bezahlen'
|
||
return angebot.grund === 'nicht_aktiv' || angebot.grund === 'gekuendigt' ? 'abo' : 'unklar'
|
||
}
|
||
const ZEITEN_ABO_WIEDER = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
|
||
|
||
// 'JJJJ-MM-TT' als TT.MM.JJJJ, sonst ''
|
||
const zeitenTag = (iso) => {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof iso === 'string' ? iso : '')
|
||
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||
}
|
||
|
||
function zeitenTile(projects, angebot = {}) {
|
||
const target = pickZeitenProject(projects)
|
||
const f = target ? zeitenOf(target) : { state: 'off' }
|
||
const preis = textOr(angebot.preis, PRICE)
|
||
const knopf = (text, { weg = '', klasse = 'kb-btn-primary fn-tile-cta' } = {}) => `<button type="button" class="${klasse}" data-view="zeiten" data-project="${esc(target.projectId)}"${weg ? ` data-zeiten-weg="${weg}"` : ''}>${text}</button>`
|
||
const chat = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
|
||
let pill = ['Aus', 'off']
|
||
let extra = ''
|
||
let action = ''
|
||
if (!target) {
|
||
extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du Öffnungszeiten live einrichten.</p>'
|
||
} else if (f.state === 'active' || f.state === 'setup') {
|
||
const aktiv = f.state === 'active'
|
||
pill = aktiv ? ['Aktiv', 'on'] : ['In Einrichtung', 'setup']
|
||
const status = typeof target.zeitenStatus?.text === 'string' ? target.zeitenStatus.text : ''
|
||
if (aktiv && status) extra += `<p class="fn-count" data-zeiten-status>${esc(status)}</p>`
|
||
if (!aktiv) {
|
||
extra += f.einbau === 'angestossen'
|
||
? '<p>Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen (meist in wenigen Minuten).</p>'
|
||
: `<p>Unser Team baut die Funktion ein${f.ticketWoid ? ` (Ticket #${esc(f.ticketWoid)})` : ''}.</p>`
|
||
}
|
||
if (target.zeitenLeer === true) extra += `<p>${aktiv ? 'Trag jetzt deine Zeiten ein.' : 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.'}</p>`
|
||
action = knopf('Zeiten bearbeiten')
|
||
// Wartungs-Abo beendet (aboEndeAt): fuer einmalig 30 € behalten, aber nur ohne Abo (kein_abo); laeuft es (nicht_aktiv, gekuendigt), steht der Hinweis ohne Knopf da
|
||
const lage = f.aboEndeAt ? zeitenBehaltenLage(angebot) : ''
|
||
const frist = lage === 'bezahlen' ? zeitenFrist(f.aboEndeAt) : ''
|
||
if (frist) {
|
||
extra += `<p class="fn-count">Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${esc(preis)}.</p>`
|
||
action += knopf(`Für ${esc(preis)} behalten`, { weg: 'behalten', klasse: 'kb-btn-outline' })
|
||
} else if (lage === 'abo') {
|
||
extra += `<p class="fn-count">${ZEITEN_ABO_WIEDER}</p>`
|
||
}
|
||
} else if (f.state === 'ordered') {
|
||
pill = ['Zahlung offen', 'setup']
|
||
extra = '<p>Zahlung wird bestätigt …</p>'
|
||
action = knopf('Status ansehen')
|
||
} else if (f.state === 'disabled') {
|
||
pill = ['Abgeschaltet', 'off']
|
||
const aboEnde = f.disabledReason === 'abo_ende'
|
||
// Daten da: Plaene gespeichert (zeitenLeer false) und die Loeschfrist (loeschungAm, Berliner Tag) ist nicht vorbei
|
||
const daten = aboEnde && target.zeitenLeer === false && !(typeof f.loeschungAm === 'string' && f.loeschungAm < berlinIsoDay())
|
||
extra = `<p>${aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ZEITEN_ABGESCHALTET}</p>`
|
||
if (daten && zeitenTag(f.loeschungAm)) extra += `<p class="fn-count">Deine Eingaben bleiben bis ${zeitenTag(f.loeschungAm)} gespeichert.</p>`
|
||
const lage = daten ? zeitenBehaltenLage(angebot) : ''
|
||
if (!daten) action = chat
|
||
else if (lage === 'bezahlen') action = knopf(`Für ${esc(preis)} wieder einschalten`, { weg: 'behalten' })
|
||
else if (lage === 'unklar') extra += `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
|
||
else extra += '<p class="fn-count">Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).</p>'
|
||
} else if (angebot.inklusive === true) {
|
||
extra = '<p>Im Wartungs-Abo enthalten.</p>'
|
||
action = knopf('Einschalten')
|
||
} else if (angebot.inklusive === false && angebot.grund === 'nicht_aktiv') {
|
||
extra = '<p class="fn-count">Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.</p>'
|
||
} else if (angebot.inklusive === false) {
|
||
extra = `<p>${esc(preis)} einmalig</p>`
|
||
action = knopf(`Öffnungszeiten einrichten · ${esc(preis)}`)
|
||
} else {
|
||
extra = `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
|
||
}
|
||
const aktiveAnzahl = projects.filter((p) => zeitenOf(p).state === 'active').length
|
||
const count = projects.length > 1 && aktiveAnzahl ? `<p class="fn-count">aktiv für ${aktiveAnzahl} von ${projects.length} Websites</p>` : ''
|
||
return `
|
||
<article class="fn-tile" data-fn-tile="zeiten">
|
||
<div class="fn-tile-head">${svg('clock')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||
<h3>Öffnungszeiten live</h3>
|
||
<p>${ZEITEN_TEXT}</p>
|
||
${extra}
|
||
${count}
|
||
${action}
|
||
</article>`
|
||
}
|
||
|
||
const soonTile = (icon, title, text) => `
|
||
<article class="fn-tile fn-tile-soon">
|
||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||
<h3>${title}</h3>
|
||
<p>${text}</p>
|
||
</article>`
|
||
|
||
export async function initFunktionen(root) {
|
||
if (!root) return
|
||
const { projects = [], termine, zeiten, features } = await request('/api/funktionen')
|
||
root.innerHTML = `
|
||
<div class="fn-tiles">
|
||
${newsTile(projects, newsOffer(features).price)}
|
||
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
||
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
|
||
${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 })
|
||
// 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
|
||
})
|
||
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
|
||
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
|
||
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
|
||
const body = knopf && document.getElementById('zeiten-body')
|
||
if (!body) return
|
||
body.dataset.wunschProjekt = knopf.dataset.project
|
||
if (knopf.dataset.zeitenWeg === 'behalten') body.dataset.wunschWeg = 'behalten'
|
||
else delete body.dataset.wunschWeg
|
||
})
|
||
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, ustHinweis } = 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, offer: { ...newsOffer(features, consentText), ustHinweis: textOr(ustHinweis, UST_HINWEIS) }, thanks })
|
||
}
|
||
|
||
function renderBooking(stage, project, { setupEstimate, offer, 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>${esc(offer.price)}</strong> <span>einmalig, <span id="news-ust-hinweis"></span></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>${esc(offer.knopf)}</button></div>
|
||
</div>`
|
||
stage.querySelector('#news-consent-text').textContent = offer.consentText
|
||
stage.querySelector('#news-ust-hinweis').textContent = offer.ustHinweis
|
||
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' ? ` ${ZU_VERTRAEGE_LINK}` : ''}</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">${esc(BANNER_PREVIEW_TEXT)}<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>`
|
||
}
|
||
|
||
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 `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
|
||
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
|
||
}
|
||
|
||
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 = `
|
||
<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.</p>
|
||
<p>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.</p>
|
||
<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>
|
||
${controllerBlock(controller)}
|
||
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</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 || 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 '<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 || [])
|
||
}
|
||
|
||
/* 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 = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
|
||
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', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
|
||
} catch {
|
||
/* Text reicht */
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
if (!heatListening) {
|
||
window.addEventListener('message', onHeatMessage)
|
||
heatListening = true
|
||
}
|
||
const big = heatSel.big === 't' ? 't' : 'd'
|
||
box.innerHTML = `
|
||
<div class="hs-head">
|
||
<h3>Heatmap</h3>
|
||
<div class="hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button" class="an-tab${vp === big ? ' active' : ''}" data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
|
||
</div>
|
||
<p>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.</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>
|
||
</div>
|
||
<div class="hs-stage" data-big="${big}">
|
||
<figure class="hs-dev hs-dev--big"></figure>
|
||
<figure class="hs-dev hs-dev--phone"></figure>
|
||
</div>`
|
||
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 = `
|
||
<h3>Analyse ausschalten</h3>
|
||
<p>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.</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 } })
|
||
box.innerHTML = `
|
||
<h3>Analyse ausgeschaltet</h3>
|
||
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
|
||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
|
||
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')
|
||
}
|
||
})
|
||
})
|
||
}
|