/* 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 { esc, safeUrl, request } from '/fn-util.js'
import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.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', ''],
['photo', 'Foto', 'ein Polaroid', ''],
['combo', 'Foto + Text', 'beides', ''],
]
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' })
}
/* ------------------------------------------------------------ Zustand --- */
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
const selection = { projectId: '' }
const newsOf = (project) => project.features?.news || { state: 'off' }
// Preis, Knopf und Zustimmungstext der News-Buchung aus dem Register (features.news der Antwort von /api/funktionen);
// fehlt das Register oder ein Wert, gelten die bisherigen Werte (PRICE und der consentText der Antwort)
const textOr = (value, fallback) => (typeof value === 'string' && value.trim() ? value : fallback)
function newsOffer(features, consentFallback = '') {
const reg = features?.news
const price = textOr(reg?.priceLabel, PRICE)
return {
price,
knopf: textOr(reg?.consent?.knopf, `Zahlungspflichtig bestellen · ${price}`),
consentText: textOr(reg?.consent?.items?.[0]?.text, consentFallback),
}
}
const isSetup = (state) => state === 'ordered' || state === 'setup'
// Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste
function pickProject(projects) {
return projects.find((p) => newsOf(p).state === 'active') || projects.find((p) => isSetup(newsOf(p).state)) || projects.find((p) => newsOf(p).state === 'off') || projects[0]
}
/* ------------------------------------------------------------ Kacheln --- */
const ICONS = {
news: '',
calendar: '',
chart: '',
shop: '',
clock: '',
}
const svg = (name) => ``
function newsTile(projects, price = PRICE) {
const states = projects.map((p) => newsOf(p).state)
const active = states.filter((s) => s === 'active').length
let pill = ['Aus', 'off']
let cta = `Einrichten lassen · ${esc(price)}`
if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } else if (states.length && states.every((s) => s === 'disabled')) { pill = ['Abgeschaltet', 'off']; cta = 'Status ansehen' }
const target = pickProject(projects)
const count = projects.length > 1 && active ? `
aktiv für ${active} von ${projects.length} Websites
` : ''
return `
${svg('news')}${pill[0]}
News-Pinnwand
Schreib kurze Neuigkeiten mit Text und Foto – sie hängen wie Zettel an einer Pinnwand auf deiner Website.
${count}
${target
? ``
: '
Sobald deine Website angelegt ist, kannst du die Pinnwand buchen.
'}
`
}
// 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 = ``
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
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 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. ${ZU_VERTRAEGE_LINK}
`
else action = `
${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}
`
const count = projects.length > 1 && active ? `
aktiv für ${active} von ${projects.length} Websites
` : ''
return `
${svg('chart')}${pill[0]}
Analyse
${text}
${count}
${devHint}
${action}
`
}
/* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst
(termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht.
termine (Antwort von /api/funktionen) traegt bei verfuegbar: true bestellbar (true | false | null = Abo nicht lesbar),
grund (kein_abo | gekuendigt | nicht_aktiv | null), preis, 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) => ``
let pill = ['Aus', 'off']
let extra = ''
let action = ''
if (state === 'active') {
pill = ['Aktiv', 'on']
extra = ''
action = knopf('Termine öffnen')
} else if (state === 'ordered') {
pill = ['Zahlung offen', 'setup']
action = knopf('Status ansehen')
} else if (state === 'setup') {
pill = ['In Einrichtung', 'setup']
action = knopf('Status ansehen')
} else if (state === 'disabled') {
pill = ['Abgeschaltet', 'off']
extra = '
Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.
'
action = knopf('Termine ansehen')
} else if (!target) {
extra = '
Sobald deine Website angelegt ist, kannst du den Terminplaner einrichten.
'
} else if (angebot.bestellbar === true) {
const preis = typeof angebot.preis === 'string' && angebot.preis.trim() ? ` · ${esc(angebot.preis)}` : ''
action = knopf(hatEntwurf(target) ? 'Fragebogen fortsetzen' : `Terminplaner einrichten${preis}`)
} else {
// 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 = `
aktiv für ${active} von ${projects.length} Websites
` : ''
return `
${svg('calendar')}${pill[0]}
Terminplaner
${TERMINE_TEXT}
${extra}
${count}
${action}
`
}
// "{n} Anfragen warten" aus anfragenOffen der aktiven Websites; ein Fehler laesst die Kachel einfach ohne Zahl stehen
async function showTermineCount(root, projects) {
const el = root.querySelector('[data-termine-count]')
const active = projects.filter((p) => termineOf(p).state === 'active')
if (!el || !active.length) return
const counts = await Promise.all(active.map((p) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(p.projectId)}`).then((d) => Number(d.anfragenOffen) || 0, () => 0)))
const open = counts.reduce((sum, n) => sum + n, 0)
if (open < 1) return
el.textContent = anfragenText(open)
el.hidden = false
}
/* Webshop: Kachel nur, wenn der Server den Shop fuer diesen Kunden freigibt (shop.verfuegbar der Antwort, Schalter SHOP_ENABLED); sonst
gar keine Kachel (der Shop startet im Pilot und wird nicht vorab angekuendigt). Die Ansicht #webshop (shop.js) zeigt die Anfrage und ihren
Stand; die Kachel kennt nur den Zustand des Funktionsdokuments (projects[].features.shop: off | ordered | setup | active | disabled) und
baut sich inline wie die Terminplaner-Kachel, damit funktionen.js keinen weiteren Import braucht. Der Preis kommt aus dem Register
(features.shop.priceLabel), Rueckfall 299 €. */
const SHOP_PREIS = '299 €'
const shopOf = (project) => project.features?.shop || { state: 'off' }
// Zielprojekt: aktiv, dann ordered/setup, dann abgeschaltet, dann das erste
function pickShopProject(projects) {
return projects.find((p) => shopOf(p).state === 'active')
|| projects.find((p) => isSetup(shopOf(p).state))
|| projects.find((p) => shopOf(p).state === 'disabled')
|| projects[0]
}
function shopTile(projects, features) {
const preis = textOr(features?.shop?.priceLabel, SHOP_PREIS)
const active = projects.filter((p) => shopOf(p).state === 'active').length
const target = pickShopProject(projects)
const state = target ? shopOf(target).state : 'off'
const knopf = (text) => ``
// Zustand -> Pille und Knopf (aus: noch keine Funktion, die Anfrage selbst zeigt die Ansicht #webshop)
const ZUSTAENDE = {
off: [['Aus', 'off'], 'Webshop anfragen'],
ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'],
setup: [['In Einrichtung', 'setup'], 'In Einrichtung'],
active: [['Aktiv', 'on'], 'Webshop öffnen'],
disabled: [['Abgeschaltet', 'off'], 'Webshop ansehen'],
}
const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off
const action = target ? knopf(knopfText) : ''
let extra = ''
if (!target) extra = '
Sobald deine Website angelegt ist, kannst du den Webshop anfragen.
'
else if (state === 'disabled') extra = '
Der Webshop ist für diese Website abgeschaltet. Deine Bestellungen kannst du noch ansehen.
'
const count = projects.length > 1 && active ? `
aktiv für ${active} von ${projects.length} Websites
` : ''
return `
${svg('shop')}${pill[0]}
Webshop
Dein eigener Online-Shop auf deiner Website. Zahlungen gehen direkt auf dein Stripe-Konto. ${esc(preis)} einmalig.
${extra}
${count}
${action}
`
}
/* Buchungskalender (Schluessel unterkunft): Kachel nur, wenn der Server die Funktion freigibt (unterkunft.verfuegbar der Antwort, Schalter
UNTERKUNFT_ENABLED); sonst gar keine Kachel. Die Ansicht #buchungen (buchungen.js) zeigt Anfrage und Stand; die Kachel kennt nur den Zustand
des Funktionsdokuments (projects[].features.unterkunft: off | ordered | setup | active | disabled), Preis aus features.unterkunft.priceLabel,
Rueckfall 199 €. */
const UNTERKUNFT_PREIS = '199 €'
const UNTERKUNFT_TEXT = 'Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung'
const unterkunftOf = (project) => project.features?.unterkunft || { state: 'off' }
function pickUnterkunftProject(projects) {
return projects.find((p) => unterkunftOf(p).state === 'active')
|| projects.find((p) => isSetup(unterkunftOf(p).state))
|| projects.find((p) => unterkunftOf(p).state === 'disabled')
|| projects[0]
}
function unterkunftTile(projects, features) {
const preis = textOr(features?.unterkunft?.priceLabel, UNTERKUNFT_PREIS)
const active = projects.filter((p) => unterkunftOf(p).state === 'active').length
const target = pickUnterkunftProject(projects)
const state = target ? unterkunftOf(target).state : 'off'
const knopf = (text) => ``
const ZUSTAENDE = {
off: [['Aus', 'off'], 'Buchungskalender anfragen'],
ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'],
setup: [['In Einrichtung', 'setup'], 'In Einrichtung'],
active: [['Aktiv', 'on'], 'Buchungskalender öffnen'],
disabled: [['Abgeschaltet', 'off'], 'Buchungskalender ansehen'],
}
const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off
const action = target ? knopf(knopfText) : ''
let extra = ''
if (!target) extra = '
Sobald deine Website angelegt ist, kannst du den Buchungskalender anfragen.
'
else if (state === 'disabled') extra = '
Der Buchungskalender ist für diese Website abgeschaltet. Deine Buchungen kannst du noch ansehen.
'
const count = projects.length > 1 && active ? `
aktiv für ${active} von ${projects.length} Websites
` : ''
return `
${svg('calendar')}${pill[0]}
Buchungskalender
${UNTERKUNFT_TEXT}. ${esc(preis)} einmalig.
${extra}
${count}
${action}
`
}
/* 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' } = {}) => ``
const chat = ''
let pill = ['Aus', 'off']
let extra = ''
let action = ''
if (!target) {
extra = '
Sobald deine Website angelegt ist, kannst du Öffnungszeiten live einrichten.
'
} 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 += `
${esc(status)}
`
if (!aktiv) {
extra += f.einbau === 'angestossen'
? '
Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen (meist in wenigen Minuten).
'
: `
Unser Team baut die Funktion ein${f.ticketWoid ? ` (Ticket #${esc(f.ticketWoid)})` : ''}.
`
}
if (target.zeitenLeer === true) extra += `
${aktiv ? 'Trag jetzt deine Zeiten ein.' : 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.'}
`
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 += `
Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${esc(preis)}.
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.'}