/* 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', ''],
['photo', 'Foto', 'ein Polaroid', ''],
['combo', 'Foto + Text', 'beides', ''],
]
const esc = (value) => String(value ?? '')
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''')
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
function fmtDay(value) {
const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
}
// "2026-10-01" in der Zeitzone Berlin, plus Tage (fuer min/max des Datumsfelds)
function berlinIsoDay(offsetDays = 0) {
const d = new Date(Date.now() + offsetDays * 86400000)
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
}
async function request(path, { method = 'GET', body, raw } = {}) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
body: raw || (body ? JSON.stringify(body) : undefined),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
if (data.field) err.field = data.field
throw err
}
return data
}
/* ------------------------------------------------------------ Zustand --- */
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
const selection = { projectId: '' }
const newsOf = (project) => project.features?.news || { state: 'off' }
const isSetup = (state) => state === 'ordered' || state === 'setup'
// Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste
function pickProject(projects) {
return projects.find((p) => newsOf(p).state === 'active') || projects.find((p) => isSetup(newsOf(p).state)) || projects.find((p) => newsOf(p).state === 'off') || projects[0]
}
/* ------------------------------------------------------------ Kacheln --- */
const ICONS = {
news: '',
calendar: '',
chart: '',
}
const svg = (name) => ``
function newsTile(projects) {
const states = projects.map((p) => newsOf(p).state)
const active = states.filter((s) => s === 'active').length
let pill = ['Aus', 'off']
let cta = `Einrichten lassen · ${PRICE}`
if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } else if (states.length && states.every((s) => s === 'disabled')) { pill = ['Abgeschaltet', 'off']; cta = 'Status ansehen' }
const target = pickProject(projects)
const count = projects.length > 1 && active ? `
aktiv für ${active} von ${projects.length} Websites
` : ''
return `
${svg('news')}${pill[0]}
News-Pinnwand
Schreib kurze Neuigkeiten mit Text und Foto – sie hängen wie Zettel an einer Pinnwand auf deiner Website.
${count}
${target
? ``
: '
Sobald deine Website angelegt ist, kannst du die Pinnwand buchen.
'}
`
}
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
const analyseOf = (project) => project.analyse || { comingSoon: true }
const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.'
const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.'
function pickAnalyseProject(projects) {
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
}
function analyseTile(projects) {
const target = pickAnalyseProject(projects)
const a = target && analyseOf(target)
if (!target || a.comingSoon) return soonTile('chart', 'Analyse', ANALYSE_TEXT)
const state = analyseState(a)
const devHint = state === 'active' && a.devMode ? `
${DEV_HINT}
` : ''
const active = projects.filter((p) => analyseState(analyseOf(p)) === 'active').length
const pill = state === 'active' ? ['Aktiv', 'on'] : state === 'disabled' ? ['Gesperrt', 'off'] : ['Aus', 'off']
let action
let text = `${ANALYSE_TEXT} Ohne Cookies, auf einem Server in Deutschland.`
if (state === 'unknown') action = '
Die Daten konnten gerade nicht geladen werden.
'
else if (state === 'disabled') { text = 'Bitte melde dich bei uns'; action = '' }
else if (state === 'active') action = ``
else if (state === 'off') action = ``
else if (a.reason === 'kein_abo') action = '
Im Wartungs-Abo enthalten.
'
else action = `
${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}
`
const count = projects.length > 1 && active ? `
aktiv für ${active} von ${projects.length} Websites
Wir bauen die Pinnwand in deine Website ${esc(project.projectName)} ein. Danach schreibst du deine Neuigkeiten selbst hier im Kundenbereich – mit Text, Foto und Anzeigedauer.
Einrichtung und Einbau durch uns
Beiträge jederzeit selbst schreiben, ändern und abnehmen
${feature.state === 'ordered' ? 'Sobald deine Zahlung bei uns ankommt, legen wir los.' : 'Sobald sie fertig ist, kannst du hier deine ersten Beiträge schreiben.'}
` })
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
else if (a.state === 'disabled') stage.innerHTML = '
Analyse ist abgeschaltet
Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.
'
else if (a.state === 'unknown') stage.innerHTML = '
Die Daten konnten gerade nicht geladen werden.
'
else if (!a.eligible) stage.innerHTML = `
Analyse nicht verfügbar
${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}
`
else renderAnalyseActivation(stage, project, reload)
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: Datenschutz
AblehnenAnnehmen
`
}
function renderAnalyseActivation(stage, project, reload) {
const a = analyseOf(project)
let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111'
stage.innerHTML = `
Analyse für ${esc(project.projectName)} einschalten
Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen. Wir speichern keine IP-Adressen und setzen keine Cookies. Alles läuft auf einem eigenen Server in Deutschland.
Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.
Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.
Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.
Farbe des Banners
${PRESET_COLORS.map((c) => ``).join('')}
Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).